폼 상태 관리를 위한 react-hook-form
react-hook-form은 복잡한 Form 상태관리를 돕는 라이브러리입니다. 너무나 유명한 라이브러리이기도 하고 인터넷에 react-hook-form에 대해서 설명하는 글이 많긴 하지만 아래 useFieldArray를 위해서 필요한 내용만 아주 간단히 정리해 보려고 합니다.
좀 뻔한 예시이지만 회원가입을 위한 controlled 폼을 만드는 상황을 가정해 보겠습니다.
import { useState } from 'react';
function Form() {
const [name, setName] = useState('');
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); // 새로고침 방지
console.log(name);
};
return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={(e) => setName(e.target.value)} />
<button type="submit">제출하기</button>
</form>
);
}
TypeScript
복사
간단하게 이름을 입력받는 폼을 만들어 보았는데요. 유효성 검사 기준을 두고 유효하지 않으면 제출하기 버튼을 비활성화 시켜야 하는 요구사항이 추가된다면 아래와 같이 작성해야 합니다.
import { useEffect, useState } from 'react';
/*
요구사항 1. 이름은 1글자 이상 5글자 이하여야 합니다.
요구사항 2. 모든 조건을 만족할 때만 제출 버튼이 활성화 되어야 합니다.
*/
function Form() {
const [name, setName] = useState('');
const [isValid, setIsValid] = useState(false);
useEffect(() => {
if (name.length < 1 || name.length > 5) {
setIsValid(false);
return;
}
setIsValid(true);
}, [name]);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); // 새로고침 방지
console.log(name);
};
return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={(e) => setName(e.target.value)} />
<button type="submit" disabled={!isValid}>
제출
</button>
</form>
);
}
TypeScript
복사
이름 하나를 입력받는데에 이렇게 꽤 많은 길이의 코드가 필요한데요. 만약에 필드가 추가되거나, 유효성 검사 기준이 늘어나거나, 에러 메시지를 보여줘야 한다거나 하는 등의 더 복잡한 요구사항이 추가되면 상태가 너무 늘어나고, 유효성 검사 로직이 너무 복잡해져서 굉장히 무거운 코드가 될 것입니다. 이 때 react-hook-form의 도움을 받으면 보다 깔끔한 코드를 만들 수 있습니다.
interface FormDataType {
name: string;
}
function Form() {
const {
register,
handleSubmit,
formState: { isValid },
} = useForm<FormDataType>({
mode: 'onChange',
});
const onSubmit = (data: FormDataType) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register('name', { required: true, minLength: 1, maxLength: 5 })}
/>
<button type="submit" disabled={!isValid}>
제출
</button>
</form>
);
}
TypeScript
복사
react-hook-form 라이브러리를 이용하면
1.
form 데이터들을 useForm 하나의 훅으로 관리할 수 있습니다. (최고!)
2.
제출 시 새로고침을 막기 위해서 submit 핸들러에 매번 추가하던 preventDefault는 handleSubmit에서 처리되기 때문에 추가하지 않아도 됩니다.
3.
register 함수로 입력값을 간단히 등록할 수 있습니다.
4.
비제어 방식으로 동작하기 때문에 직접 ref를 사용하지 않고도 불필요한 리렌더링을 막을 수 있습니다. (mode: 'onChange' 를 추가해서 제어 방식으로도 동작시킬 수 있어요.)
5.
register에 유효성 조건과 에러 메시지까지(!) 전달해서 유효성 확인 로직을 간단하게 처리할 수 있습니다. (zod 같은 schema validation 라이브러리와 함께 사용하면 정말 강력합니다.)
이렇게 설명하니 마치 홈쇼핑 같네요… 하지만 복잡한 폼 구현에 고통받고 계시다면 react-hook-form 구매를 망설이실 필요가 없습니다!
같은 폼이 여러 개 있다면 useFieldArray
이번에 개발한 MODDO의 정산 폼은 아래와 같이 생겼습니다.
2025.3.10 새로운 스크린샷으로 변경함
지출 내역을 N차로 입력할 수 있어야 했기 때문에 같은 모양의 폼이 여러 개 있어야 했고, 상단의 지출 추가 버튼을 이용해서 동적으로 폼을 추가하고 X 버튼을 눌러 폼을 삭제할 수 있어야 했습니다. 정말 난감했는데… react-hook-form의 useFieldArray 문서를 보고 혹시나 하고 읽다가 감동의 눈물을 흘렸습니다. (정말입니다.)
useFieldArray는 이름에 잘 드러나 있듯이 useForm으로 생성한 폼을 배열로 관리하며 동적으로 추가하거나 삭제할 수 있습니다. 사용하는 방법도 간단합니다.
useForm을 이용해서 폼을 만들고
const {
register,
handleSubmit,
control,
formState: { errors },
} = useForm({
defaultValues: { nameList: [] },
});
TypeScript
복사
const { fields, append, remove } = useFieldArray({
control,
name: 'nameList',
});
TypeScript
복사
폼 추가와 삭제는 append와 remove 함수로 할 수 있고요.
const handleAddName = (e) => {
e.preventDefault();
append({ name: '' }); // 기본 값으로 빈 문자열 추가
};
const handleDeleteName = (index) => {
remove(index); // 해당 인덱스의 이름 폼을 삭제
};
TypeScript
복사
폼 필드는 fields로 접근해서 랜더링 할 수 있습니다.
{fields.map((field, index) => (
<input
{...register(`nameList.${index}.name`, {
required: '이름이 필요합니다.',
})}
defaultValue={field.name}
/>
))}
TypeScript
복사
마지막으로 폼 데이터에는 아래와 같이 접근할 수 있습니다.
const handleFormSubmit = (data) => {
onSubmit(data.nameList);
};
TypeScript
복사
참 쉽죠? 개인적으로 정산 폼을 개발하는 과정이 굉장히 챌린징했는데 (이유는 아래에 나옵니다..) useFieldArray 덕분에 개발 시간을 많이 단축했어서 너무나 소개하고 싶었답니다.
사실 소개하고 싶은 내용은 여기까지인데요. 정산 폼에 대한 글을 쓰는 김에 개발 과정에서 useForm으로 삽질했던 경험도 함께 적으면 좋을 것 같아서 아래에 간단히 정리해보려 합니다.
useForm 기본 값 다루기 (서버 데이터)
아래에 정리된 것 중 해결 방법은 개발 마감 기한에 쫓기며 어떻게든 개발 마감을 짓는 과정에서 작성된 방식입니다. 개인적으로도 방법을 고민해서 더 나은 방법을 찾아보려는 중이니 읽으시는 분께서도 ‘이렇게 해결한 녀석도 있구나’ 하는 정도로만 참고해 주시면 감사하겠습니다… 🥹
혹시 더 좋은 방법에 대한 아이디어가 있으시다면!!! 언제든 댓글 달아주시면 더더욱 감사하겠습니다…!!!
useForm에 전달하는 데이터 중 defaultValues는 폼의 초기 값을 정의할 수 있는 프로퍼티입니다. 지출 폼에서는 지출일 값을 입력 시점의 날짜로 지정하고, 지출 참여자를 기본적으로 전체 모임 참여자로 지정하기 위해서 defaultValues를 사용했는데요. 기본적으로는 폼 데이터의 구조에 맞게 객체로 defaultValues를 전달하는 방식으로 사용할 수 있습니다.
const formMethod = useForm({
defaultValues: {
amount: 0,
content: '',
date: format(new Date(), 'yyyy-MM-dd'),
memberExpenses: [],
}
})
TypeScript
복사
그런데 때로는 기본값을 서버에서 불러와서 사용해야 하는 상황이 생깁니다. 바로 제 상황인데요. (…) 참여자 목록 데이터에 해당하는 memberExpenses의 초기값은 서버에서 참여자 목록을 불러와서 지정해야 했기 때문입니다.
서버 데이터의 경우
사실 처음에는 다른 데이터들처럼 useQuery를 이용해서 참여자 목록을 불러와서 defaultValues에 추가해주려 했습니다. 뒤에 나오겠지만 변경된 참여자 정보도 이 페이지에서 다뤄야 했기 때문에 좋은 선택이라고 생각했거든요. 하지만 defaultValues는 한번 지정하면 변경할 수 없습니다. useQuery를 이용해서 참여자 목록을 불러오더라도 처음 useForm이 실행될 당시에 그 데이터가 없다면 사용할 수 없는 것이죠.
한번도 form의 기본값으로 서버 데이터를 써 본적이 없어서 좀 난감했는데 정답은 의외로 공식문서에 있었습니다. defaultValues 문서를 다시 한번 읽어보니 아래의 예시가 있더라고요.
// set default value async
useForm({
defaultValues: async () => fetch('/api-endpoint');
})
TypeScript
복사
defaultValues의 타입은 FieldValues | () => Promise<FieldValues> 이렇게 두가지 타입으로 정의되어 있기 때문에 비동기적인 데이터도 받아서 기본 값으로 설정할 수 있었습니다. react-hook-form의 강력한 기능 덕분에 이 문제상황은 비교적 간단하게 해결될 수 있었습니다. 하지만…
useFieldArray를 사용하는 경우
사실 대부분의 상황에서는 폼을 하나만 사용할테니 이런 상황이 별로 없을 것 같은데요. 문제는 저희는 useFieldArray를 이용한 동적 폼 생성을 사용하고 있다는 점이었습니다. 폼을 추가하기 위한 append 함수는 defaultValues를 이용해서 폼을 추가하는 것이 아니라 직접 값을 파라미터로 넣어주는 방식으로 동작합니다. 그래서 많은 사례들에서는 defaultValues에 전달할 값을 외부에 선언해두고, defaultValues 와 append 에 전달하는 식으로 구현했더라고요. 하지만 저희는 위에서 보았듯 defaultValues 내부에서 서버 데이터를 받아와서 사용하고 있기 때문에 이 방식을 사용할 수 없었습니다.
많은 고민과 삽질과 검색의 시간이 있었지만 결론부터 말하면 useMemo를 이용해서 append에 전달할 기본 값들을 기억하고 (이 때 기본 값들은 아쉽게도 defaultValues와는 별개로 관리되는 값입니다.) 참여자 목록에 업데이트 될 때 마다 기본 값들을 수정해주는 방식을 선택했습니다. 간단하게 보면 아래와 같이 구현되어 있는건데요.
const formMethods = useForm({
defaultValues: async () => {
const groupData = await group.get(groupToken); //1️⃣ 서버에서 참여자 목록을 불러와서
setGroupInfo(groupData); // 2️⃣ 상태에 저장합니다.
return {
expenses: [
{
...defaultValues,
memberExpenses: groupData.members.map((member) => ({
id: member.id,
name: member.name,
// 생략
})),
},
],
};
},
});
const defaultFormValue = useMemo(() => {
// 저장된 참여자 목록을 이용해서 append에 전달할 기본 값을 만들어 저장합니다.
if (!groupInfo) {
return defaultValues;
}
return {
...defaultValues,
memberExpenses: groupInfo.members.map((member) => ({
id: member.id,
name: member.name,
// 생략
})),
};
}, [groupInfo]);
TypeScript
복사
서버에 요청을 한번만 보내기 위해서 defaultValues를 구성할 때 사용한 데이터를 따로 상태에 저장해두고 defaultFormValue 에서 사용할 수 있도록… 했는데 굉장히 마음에 들지 않습니다.
1.
참여자 목록을 defaultValues 외부에서 사용하기 위해서 (오직 그 목적으로만) 정의한 groupInfo라는 상탤가 불필요하게 느껴지고
2.
이 페이지에는 원래 모임 참여자를 추가하고 제거하는 기능이 있었는데 현재 코드에서는 따로 서버에 한번 더 요청하지 않는 이상 변경된 모임 참여자를 defaultFormValue에 반영할 수 없습니다. 무조건 defaultValues 내에서 api를 한번 호출해야 하기 때문이죠.
2번 문제에 대해서 고민하다가 참여자 추가/제거 기능을 기획적으로 다시 논의해봐야 하는 상황이 생겨서 보류되고 위에 첨부한 코드로 일단은 일단락되었는데요. 이 기능이 다시 부활한다면 어떻게 해야 할지 굉장히 고민입니다. useQuery를 추가하는 것이 정말 최선의 방법일까요?? 
여담이지만 아까 언급되었던 많은 고민과 삽질과 검색의 시간 중에 tanstack form 라이브러리에서는 tanstack-query와의 연결이 잘 되어 있다는 사실을 알게 되었었는데요. 제가 고민하던 내용을 한방에 해결할 수도 있는 것 같아서 좀 마음이 아팠습니다…
마무리
외주 개발을 하면서 마주했던 폼이 가장 복잡한 폼일거라고 생각했는데 (외주 페이지를 공개할수는 없지만 표 형태의 화면에서 각 row의 상태에 따라서 폼을 띄워줘야 하는 UI 였어요. ) 다른 팀원이 저에게 ‘정산 페이지를 벗어나질 못하시네요…’ 라고 안타까워 해줄 정도로 이번 정산 폼을 만든 경험은 정말 챌린징한 경험이었습니다… 다행히도 useFieldArray를 알게 된 덕분에 걱정을 90% 덜었지만 남은 10%가 너무 뼈아프네요. 좋은 방법을 생각해내어 코드를 개선하게 된다면 이 글도 바로 업데이트하겠습니다.
저처럼 이렇게 복잡한 폼을 만들어야 하는 상황에 놓이셨다면 react-hook-form을 이용해보시는 것을 추천드립니다! 그리고 이 글이 useFieldArray를 사용하실 분들께 도움이 되었으면 좋겠습니다. 
useFieldArray가 적용된 MODDO가 궁금하다면?
모임 정산 서비스
MODDO
