프로젝트를 진행하면서 발생했던 react hook form관련 이슈입니다.
회원가입과 게시물 작성 폼에서validation과 input value를 더 쉽게 관리할 수 있다는 기대감을 가지고 react-hook-form을 프로젝트에 도입해 보았습니다.
하려고 했던것은
- react hook form의 register를 이용하여 input file의 file객체를 handleSubmit의 onValid data로 넘기기.
- 업로드 이미지의 preview를 view에 출력하기 위해 input file에 onChange이벤트가 실행될 때 마다 FileReader를 통해 File객체를 인코딩하여 상태값으로 업데이트 하기.
이렇게 두가지 입니다.
이 상황에서 react-hook-form의 register옵션에 있는 onChange와 일반 이벤트 핸들러 onChange와의 차이를 알지 못해 '뭐지' 싶은 상황을 많이 겪었습니다🥲
처음에 썻던 코드입니다. 일반 onChange이벤트로 encodeFile함수를 실행했습니다.
encodeFile은 event.target으로 이미지 파일을 받아 FileReader로 만든 dataUrl을 담은 객체를 상태값으로 업데이트 해주는 함수입니다.
// useUpload.tsx
const encodeFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
const imageFile = event.target.files;
// console.log(imageFile);
if (!imageFile || imageFile.length < 0) return;
[...imageFile].forEach(file => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
const result = reader.result as string;
const obj = {
name: file.name,
dataUrl: result,
};
setImgsrc(prev => [...prev, obj]);
};
});
};
// index.tsx
<form onSubmit={handleSubmit(valid)}>
<input
type="file"
{...register("image", {
required: "이미지를 등록해주세요.",
})}
accept="image/png, image/jpeg"
multiple
className="hidden"
onChange={encodeFile}
/>
</form>
문제 → handleSubmit onValid data의 FileList length가 0.
원인 → react-hook-form의 register함수의 onChange속성에 encodeFile함수를 등록한것이 아니라 일반 onChange를 사용했기 때문에 파일 변경 이벤트가 react-hook-form의 폼 데이터에는 반영되지 않는다.
여기서 질문! 🔍
Q.일반 이벤트 핸들러의 event.target.value가 react hook form의 handleSubmit valid함수의 data객체로는 전달될 수 없나?
→ Yes.
React Hook Form
에서는handleSubmit
함수를 사용하여 폼을 제출할 때data
객체를 반환합니다. 이data
객체는 등록된 입력 요소의name
속성과 해당 입력 요소의 값을 매핑한 객체입니다.즉,
React Hook Form
에서data
객체를 얻으려면 등록된 입력 요소의name
속성을 사용하여 값을 찾아야 합니다.event.target.value
는 기존의onChange
이벤트 핸들러에서 입력 요소의 값을 추출하는 방법이므로,React Hook Form
에서는event.target.value
를 사용할 수 없습니다.
react-hook-form의 data객체는 라이브러리 안에서 formData등으로 따로 관리되므로 e.target.value를 전달할 수는 없었습니다.
그래서 단순히 일반 onChange에서 register함수의 옵션에 있는 onChange이벤트를 등록하는 것으로 수정했습니다.
<form onSubmit={handleSubmit(valid)}>
<input
type="file"
{...register("image", {
required: "이미지를 등록해주세요.",
onChange: e => encodeFile(e)
})}
accept="image/png, image/jpeg"
multiple
className="hidden"
/>
</form>
문제 → encodedFile함수의 setImgsrc가 실행이 됐다 안됐다 하는 문제가 발생.
여기서 질문! 🔍
Q.react-hook-form의 onChange이벤트는 컴포넌트가 리렌더링 되지 않는건가?
→ Yes.
React Hook Form은 입력 요소의 값을 내부 상태로 관리하므로, Controller컴포넌트의 onChange
이벤트 핸들러에서 입력 요소의 값이 변경되어도 컴포넌트가 다시 렌더링되지 않습니다.
register옵션의 onChange로 실행한 함수 내부의 state를 변경시킬 수 없었습니다..
관련된 추가 내용 :
💡react-hook-form
의 Controller
컴포넌트의 onChange
이벤트 핸들러에 setState
를 사용하는 것은 일반적으로 권장되지 않습니다.
react-hook-form
은 컨트롤러가 폼의 state를 관리하도록 설계되었습니다. 따라서 react-hook-form
은 컨트롤러의 값을 업데이트하는 데 필요한 모든 state를 자동으로 관리합니다.
만약 react-hook-form
의 Controller
컴포넌트와 함께 setState
를 사용하면, 이는 react-hook-form
의 내부 state와 컴포넌트의 state가 서로 충돌하게 됩니다. 이는 예기치 않은 동작을 유발할 수 있으며, 디버깅이 어렵고 유지보수가 어려워집니다.
따라서, react-hook-form
을 사용할 때는 Controller
컴포넌트의 onChange
이벤트 핸들러에서 setState
를 사용하지 않는 것이 좋습니다. 대신, react-hook-form
의 내부 state를 업데이트하는 함수인 setValue
를 사용하거나, 컨트롤러의 값을 업데이트하는 데 필요한 로직을 컨트롤러 외부에서 처리하는 것이 좋습니다.
세번째 시도입니다. 왠지 딱봐도 에러가 날것 같지만 궁금해서 한번 register옵션의 onChange와 일반 onChange를 함께 사용해 보았습니다.
<form onSubmit={handleSubmit(valid)}>
<input
type="file"
{...register("image", {
required: "이미지를 등록해주세요.",
onChange: e => encodeFile(e)
})}
accept="image/png, image/jpeg"
multiple
className="hidden"
onChange={encodeFile}
/>
</form>
문제 → 당연히 제대로 동작하지 않음. handleSubmit함수가 FileList를 감지하지 못함.
해결 → register의 onChange핸들러 내부에서 setValue 함수 등을 이용하여 react-hook-form의 폼 데이터에 직접 값을 할당해주어야 한다.
handleSubmit에서 FileList객체를 감지하게 하기 위해 encodeFile함수에서 반환된 fileList를 setValue
를 이용하여 전달했습니다. setValue
는 react-hook-form에서 제공하는 함수입니다.
또 다른문제 → error alert가 나오거나 data fileList객체의 length가 0.
하지만 다른 문제가 발생했습니다. error alert가 나오거나 data fileList객체의 length가 0으로 나왔습니다.
일단 encodeFile이 반환하는 값을 콘솔로 확인해서 fileList값을 확인했으나 에러가 생기는 이유를 알 수 없었습니다. 그것도 뜬금없이 alert으로 에러가 나오다니🥲
여기까지 왔으니 다른 방법은 없나 생각해보게 되었습니다.
결국 file객체를 전달하는 방법은 register를 이용하지 않는 방법도 있지만 setImgsrc
를 실행시키기 위해서는 일반 onChange이벤트 핸들러를 사용하는것이 불가피하기 때문에 onChange 그대로 두고 File의 정보는 imgsrc(state)에 같이 포함시켜 상태로 관리하기로 결정했습니다.
// useUpload.tsx
const encodeFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
const imageFile = event.target.files;
if (!imageFile || imageFile.length < 0) return;
[...imageFile].forEach(file => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
const result = reader.result as string;
const obj = {
name: file.name,
dataUrl: result,
file: file, // file정보 추가
};
setImgsrc(prev => [...prev, obj]);
};
});
};
// handleSubmit valid함수
const valid = async (data: CreateState) => {
const imageurlList: string[] = [];
imgsrc.forEach(item => {
// s3 upload
uploadImage(item.file);
const imageurl = createImageUrl(item.file);
imageurlList.push(imageurl);
});
createProduct(data, imageurlList); // imgsrc상태를 참조하여 data와 함께 payload에 포함시켜 전송.
};
이렇게 정리.
(불과 한달전에 쓴 내용인데 지금보니 왜 벌써 흑역사같은지??😳)
지금 보니 두번째에서 register onChange로 이벤트객체를 보냈을 때 왜 handlesubmit data에서 FileList가 나오지 않았는지 이유를 모르겠어요..지금은 리팩토링을 거치면서 파일 업로드하는 부분만 컴포넌트를 분리해 놓은 상태인데 onChange로 데이터에 파일이 잘 들어옵니다. 아마 저때 컴포넌트 구조가 뭔가 잘못되었던것 같..아요..👉👈
그리고 생각해보니 이미지 파일은 제출되기 전에 dataUrl로 가공을 거쳐야하기도 하고 중간에 리스트를 추가하거나 삭제하는등 수정이 있을 수 있기 때문에 어차피 독립된 상태로 분리해서 따로 포함시키는게 맞는것 같습니다!
<UploadImages
register={register}
deleteImage={deleteImage}
encodeFile={encodeFile}
imgsrc={imgsrc}
/>
그리고 원래는 컴포넌트로 분리하면서 register를 props로 보내줬었는데,
<FormProvider {...method}>
<UploadImages
deleteImage={deleteImage}
encodeFile={encodeFile}
imgsrc={imgsrc}
/>
</FormProvider>
이렇게 FormProvider
로 감싼뒤 method를 그대로 보내주고 중첩된 컴포넌트에서 useFormContext
로 똑같이 register를 사용해도 상위 컴포넌트의 data로 포함시킬 수 있습니다.👍 (Context API같이 동작)
또 다른 문제가 생겼습니다.
하단의 카테고리에서 선택하는 값을 데이터로 포함시켜야 하기 때문에 react-hook-form의 폼데이터로 함께 관리하려고 input radio로 선택항목을 만들고 submit요청시 data의 값으로 들어가게 하려고 했습니다.
<ul className="h-[265px] w-full overflow-y-scroll [&>li]:text-textColor-gray-100">
{list.map((listItem, i) => {
const radioId = `${key}-${i}`;
return (
<li key={radioId}
className="cursor-pointer p-2 hover:bg-[#f7f7f7] hover:text-common-black"
onClick={e => selectTabItem(e, name)}
>
<label htmlFor={radioId}>
{listItem}
<input
{...register(`${key}`)}
type="radio"
value={listItem}
id={radioId}
className="hidden"
/>
</label>
</li>);
})}
</ul>
문제 → data의 category값이 계속 null로 나오는것 확인.
원인 → 라디오 버튼은 동일한 name값을 가진 여러개의 선택 가능한 옵션 중 하나만 선택되어야 하기 때문에 name속성값이 동일해야 하는데 name속성이 없음.
<input
{...register(`${key}`)}
type="radio"
value={listItem}
id={radioId}
name={key}
className="hidden"
/>
name속성을 key로 설정하여 고유한 name값을 부여했습니다.
하지만 register 함수에 전달되는 인자값의 name 속성과, 해당 입력 필드의 name 속성이 동일한데도 계속 null이 반환되었습니다.
register
함수가 폼 컴포넌트 내에서 호출되기 전에, 컴포넌트가 마운트되기 전에 호출된 경우 null
을 반환할 수 있습니다. 이 경우에는 폼 컴포넌트가 마운트된 이후에 register
함수를 호출하도록 수정해야 합니다.register
함수에 전달되는 인자값의 name
속성과, 해당 입력 필드의 name
속성이 일치하지 않는 경우에도 null
을 반환할 수 있습니다. 이 경우에는 두 속성의 값을 일치시켜야 합니다.register
함수는 null
을 반환할 수 있습니다. 이 경우에는 입력 필드의 name
속성이 올바르게 설정되었는지, 입력 필드가 컴포넌트의 렌더링 결과에 포함되었는지 확인해야 합니다.register
함수는 null
을 반환할 수 있습니다. 이 경우에는 입력 필드가 조건부로 렌더링되는 이유를 확인하고, 필요한 경우 해당 조건을 수정해야 합니다.현재상황에서 1,2번은 해당하지 않고 3,4번이 유력한데 먼저 li를 form에 포함시키지 않는 실수가 있어서 form내부로 위치를 변경했습니다.
하지만 계속 null이 반환되었고 마지막으로 해당 컴포넌트의 렌더링 조건 삭제 후 다시 시도하자 마침내 값이 제대로 반환되었습니다.
결론 → react-hook-form의 register함수에서 반환하는 폼 데이터에 들어가는 입력필드를 조건부로 렌더링하는 경우 register함수에 해당 값이 null로 반환된다.
디자인상 선택창이 조건부로 렌더링 되는것이 필수라 결국 선택 필드값들은 tabItem state를 만들어 관리하고 data를 보낼때 payload객체에 함께 포함시켜 해결했습니다.
const createProduct = async (data: any, imageurlList: string[]) => {
const payload = {
data,
imageurlList,
tabItem, // 수정: tabItem 추가
};
const response = await axios.post("/api/products", {
headers: { "Content-Type": "application/json" },
payload,
});
};
편리하게 사용하기 위해 react-hook-form을 도입했는데 생각보다 수월하게 사용하지 못하고 오히려 여러가지 이슈로 딜레이를 겪었습니다.
register와 제공하는 여러옵션들이 편리하기도 했지만 원하는 기능을 구체적으로 구현하기 위해서는 react-hook-form에서 제공하는 기능들만으로는 한계가 있을 수도 있고 여러 조건들에서 원하는 값들을 전달하려다보니 react-hook-form의 formData와 추가적인 input state들이 혼재되면서 오히려 복잡성이 증가하는 듯한 느낌을 받아서 개인적으론 좋지 않은 경험이었다고 생각합니다..
물론 100%활용을 못한 제잘못이기도 하지만 그냥 쉽게 '이렇게하면 되겠지??' 했다가는 예상치 못한 상황이 계속 생겨나는...😫 (리렌더링을 줄일 수 있다는 측면에서는 기존에 state로 관리했을 때보다 성능면에서 무조건 좋긴 합니다.)
라이브러리를 무조건적으로 사용하기 보다는 상황을 고려해서 좀 더 적합할 때만 사용해야겠다는 생각이 들었습니다.🤔
그럼 끝🙇♀️