로그인 페이지나 input 이 많은 설문지를 관리하기 위해서 보통 상태를 통해 관리한다.
const [firstName, setFirstName] = useState('');
return (
<input
value={firstName}
onChange={setFirstName(e.target.value)}
/>
)
이런 방법은 관리해야할 input 이 별로 없는 경우 쉽게 구현이 가능하고 성능상의 문제도 디바운싱 등으로 해결하면 된다.
하지만 관리해야할 input 이 많아진다면 어떻게 해야할까?
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [gender, setGender] = useState('M');
const [phoneNumber, setPhoneNumber] = useState('01012345678');
이렇게 관리해야할 상태가 많아지는 것은 물론이고, 매번 상태가 변할때마다 페이지 리렌더링이 발생하니 성능적인 측면에서도 좋지 못할 것이다.
react-hook-form 은 이러한 문제를 uncontrolled 방식으로 해결한다. 쉽게 말하자면 상태로 값을 관리하는 것이 아닌 input 의 ref 객체를 참조하여 거기서 값을 가져오는 것이다.
폼 전체를 쉽게 관리하기 위한, react-hook-form 의 주춧돌 훅이다.
const {
register,
watch,
formState,
handleSubmit,
reset,
getValues,
// ... 아주 많은 메서드를 가지고 있다
} = useForm();
이렇게 반환되는 값 뿐만 아니라 useForm 자체에도 많은 프로퍼티를 넘겨줄 수 있다.
useForm({
defaultValues,
values,
errors,
mode
// ... 역시 많은 프로퍼티
})
많은 프로퍼티들이 있으나 그걸 모두 번역하기보단 form 을 관리하기 위해 필수적인 값들만 다뤄보려고 한다.
mode유효성 검사를 언제 실행할 것인지 설정할 수 있다. (기본은 onSubmit으로 submit이벤트가 발생할 때 유효성 검사를 진행한다.)
onChange | onBlur | onSubmit | onTouched | all = 'onSubmit'
useForm({
mode: 'onSubmit' // onSubmit 이벤트 발생시 폼 제출
})
onChange
onBlur
onTouched
onSubmit
defaultValuesdefaultValues 는 form 을 이루고 있는 기본 값을 설정한다. 이때 주의해야할 점은 undefined 등의 값을 넣어주면 안된다.
useForm({
defaultValues: {
firstName: '',
lastNAme: ''
}
})
valuesdefaultValues 가 내부적으로 input 을 통해 관리되는 값들이라면, values 는 외부요소에 의해 업데이트 되는 값이다.
const data = fetch('/api');
useForm({
values: data // data 가 업데이트 되면 업데이트
})
errorsvalues 와 마찬가지로 폼이 서버나 외부의 상태에 의해 변경되는 경우 사용하며, error 가 발생한 경우를 위해 쓰인다.
const { data, errors } = fetch('/api');
useForm({
values: data,
errors
})
resetOptionsvalues 나 defaultValues 에 업데이트가 일어나면 내부적으로 reset API 가 동작한다. 이 때 리셋과 관련된 옵션을 설정한다.
keepDirtyValues: boolean
keepErrors: boolean
useForm({
values,
resetOptions: {
keepDirtyValues: true,
keepErrors: true,
},
})
keepDirtyValues
keepErrors
formStateformState 는 폼의 상태와 관련된 상태를 제공해준다.
isSubmitting | isSubmitted | isSubmitSuccessful | isLoading
isValid | isValidating
isDirty | dirtyFields
touchedFields | defaultValues | submitCount
errors
const { formState } = useForm();
const { isSubmitting, isSubmitted, isValid } = formState;
또 헷갈리는 상태에 대해서만 설명하자면…
isSubmitted 와 isSubmitSuccessful 의 차이
isSubmitting 과 isLoading 의 차이
async 를 기본값으로 설정한 경우가 해당된다.isValidating
isDirty
defaultValues 로 설정한 값과 비교하여 같은지를 불리언 값으로 반환한다.dirtyFields
touchedFields
register()uncontrolled 한 폼을 만들기 위해 가장 기본이 되는 메서드이다.
input 하나를 form 에 등록시키고, input 속의 값을 가져오기 위한 용도로 사용한다.
👉🏻 register 사용방법
const { register } = useForm();
return (
<input {...register('fisrtName')} />
<input {...register('lastName')} />
)
👉🏻 register 반환값
직접 register 의 반환값을 사용해서 각 input 을 세세하게 컨트롤 할 수 있다.
onChange | onBlur | name | ref
const {
onChange,
onBlur,
name,
ref
} = register('firstName');
👉🏻 register configure
또, input 요소에 여러 옵션을 두듯이 register 를 통해 옵션을 관리할 수도 있다.
required | disabled
maxLength | minLength | max | min
pattern | validate
value | valueAsNumber | valueAsDate | setValueAs
onChange | onBlur
shouldUnRegister | deps
return (
<input
{...register('firstName', {
maxLength: {
value: 5,
message: '이름은 최대 5글자까지만 가능합니다!',
},
required: {
value: true,
message: '해당 양식을 채워주세요~',
},
validate: {
isEmpty: (value) => value.trim().length > 0 || '채워주세요!',
},
})}
/>
)
required
disabled
maxLength & minLength
text 인 경우 글자의 최대길이와 최소 길이를 설정한다.min & max
number 인 경우 숫자의 최소크기와 최대크기를 설정한다.pattern & validate
pattern 의 경우 정규식과 일치하지 않으면 message 프로퍼티에 할당하는 메세지 문구를 보여준다.pattern: {
value: regex,
message: 'error message'
}validate 의 경우 사용자가 직접 할당한 콜백함수를 할당할 수 있다.// 콜백함수로 할당하는 경우
validate:(value) => value.trim().length > 0 || '채워주세요!',// 객체 형태로 validate 를 할당하는 경우
<input
{...register("test1", {
validate: {
positive: v => parseInt(v) > 0,
lessThanTen: v => parseInt(v) < 10,
validateNumber: (_, values) =>
!!(values.number1 + values.number2),
}
})}
/>value
valueAsNumber & valueAsDate
setValueAs
setValueAs: (value) => value.trim();onChange & onBlur
shouldUnRegister
deps
아래와 같이 이뤄진 form 양식을 react-hook-form 으로 관리하기 위해 어떻게 해야할까?
- FirstName
- LastName
- Select
- Checkbox
- radio
아래와 같이 register 로 각 이름을 input 에 등록해주면 된다. 이때 여러 개 중 값이 하나일 경우엔 같은 이름으로 등록해주어야 한다.
import { useForm } from 'react-hook-form';
export default function App() {
// 1. form으로 관리할 기본 값들을 설정해준다
const { register, handleSubmit } = useForm({
defaultValues: {
firstName: '',
lastName: '',
category: '',
checkbox: [],
radio: '',
},
});
return (
// 자체 내장 메서드인 handleSubmit 으로 submit 을 관리해준다
<form onSubmit={handleSubmit(console.log)}>
<input
{...register('firstName', { required: true })}
placeholder='First name'
/>
<input
{...register('lastName', { minLength: 2 })}
placeholder='Last name'
/>
<select {...register('category')}>
<option value=''>Select...</option>
<option value='A'>Category A</option>
<option value='B'>Category B</option>
</select>
<input {...register('checkbox')} type='checkbox' value='A' />
<input {...register('checkbox')} type='checkbox' value='B' />
<input {...register('checkbox')} type='checkbox' value='C' />
<input {...register('radio')} type='radio' value='A' />
<input {...register('radio')} type='radio' value='B' />
<input {...register('radio')} type='radio' value='C' />
<input type='submit' />
</form>
);
}
react-hook-form 은 uncontrolled 기반의 라이브러리이다. 직접 UI 를 만드는 경우라면 원하는 대로 동작하겠지만 외부의 컴포넌트(chakra, MUI 등)는 state 를 기반으로 동작하는 controlled 방식의 컴포넌트일 수 있다.
이 경우 useForm 만 사용해서는 원하는 방식으로 동작하지 않을 수 있는데, useController 과 Controller 가 중간다리 역할을 해줄 수 있다.
예시로 chakra ui 의 Radio 컴포넌트를 가져왔다.
<RadioGroup /> 의 값을 바꾸기 위해선 onChange 에 setGender 를 넣어 값이 바꿔줘야 한다.
const [gender, setGender] = useState<string>('M');
return (
<form onSubmit={handleSubmit(submitForm)}>
<RadioGroup
onChange={setGender}
value={gender}>
<Radio value='M'>남자</Radio>
<Radio value='F'>여자</Radio>
</RadioGroup>
<Button type='submit'>제출</Button>
</form>
);
이 경우 값을 변경할 때마다 Radio 컴포넌트 뿐 아니라 버튼 컴포넌트까지, 즉 페이지 전체에 렌더링이 발생하는 걸 확인할 수 있었다.

- Radio 컴포넌트와 관련없는 컴포넌트는 렌더링 할 필요가 없는데?
- 하지만 useForm 으로 값을 관리하고 싶어!
이 요구를 모두 충족하기 위한 것이 바로 Controller 컴포넌트다.
controller 를 사용해 RadioGroup 컴포넌트 감싸기Controller 컴포넌트로 RadioGroup 컴포넌트 전체를 래핑하면 된다.
const { control } = useForm({
defaultValues: {
gender: 'M',
},
});
return (
<form onSubmit={handleSubmit(submitForm)}>
<Controller
name={'gender'}
control={control}
render={({ field: { onChange, value } }) => (
<RadioGroup onChange={onChange} value={value}>
<Radio value='M'>남자</Radio>
<Radio value='F'>여자</Radio>
</RadioGroup>
)}
/>
<Button type='submit'>제출</Button>
</form>
);
아까 전과 다르게 RadioGroup 내부의 컴포넌트만 리렌더링 되는 모습을 확인할 수 있다.

물론, 제출 버튼을 클릭했을 때 RadioGroup 내부의 값을 가져오는 것도 정상적으로 동작한다.

controller 의 프로퍼티<Controller
name={'gender'}
control={control}
render={({ field: { onChange, value } }) => (
<RadioGroup onChange={onChange} value={value}>
<Radio value='M'>남자</Radio>
<Radio value='F'>여자</Radio>
</RadioGroup>
)}
/>
대표적으로 3개의 프로퍼티를 가진다.
name
control
control 객체를 등록한다.const { control } = useForm();render
<Controller
render={({ field: {} }) => (
<component />
)}
/>field 프로퍼티는 흡사 register 메서드와 비슷한 값들을 가지고 있다.
field: {
onChange,
onBlur,
value,
disabled,
// ... 등등
}
여기서 value 를 주의해야 하는데, 여기서 value 는...
<input value={value} />
위와 같은 용도로 사용되는 것이 아니라, react-hook-form 에서 값의 변화를 감지하는 훅인 useWatch 를 사용하여 변경되는 시점의 필드 값을 가져온다.
그리고 만약 useForm에서 유효성 검사시점을 위해 mode 프로퍼티를 onBlur 로 설정해주었다면 onBlur를 넘겨줘야 제대로 유효성검사를 할 수 있다.
Controller 의 용도가 어떻게 쓰이는지는 알았다. 그렇다면 useController 는 무엇을 위해 존재할까?
우선 useController 를 뜯어보면 다음과 같다.
const { field } = useController({
name: 'gender',
control: control,
})
Controller 가 가진 프로퍼티와 반환값을 그대로 가져온다는 걸 알 수 있다.
사용은 아래와 같이 한다.
const { field } = useController({
name: 'gender',
control: control,
})
return (
<RadioGroup onChange={field.onChange} value={field.value}>
<Radio value='M'>남자</Radio>
<Radio value='F'>여자</Radio>
</RadioGroup>
)
Controller 컴포넌트로 래핑하지 않아도 되기 때문에 가독성이 더 쉬워졌다.
재사용 가능한 controlled input 을 만들기 위해 사용 가능하고, 훅의 형태라 로직을 분리하기에도 더 좋다는 장점이 있다.
그래서 그런지 controller 의 개념을 알아야 useController 를 쓸 수 있음에도 불구하고, react-hook-form 문서에선 useController 를 Controller 보다 상위 문서에서 설명하고 있다.

react-hook-form 은 배열을 위한 메서드도 제공한다.
다음과 같은 값이 있다고 가정해보자. 여러개의 성별을 받아야 한다.

interface FormValues {
genders: { value: string }[];
}
useForm<FormValues>({
defaultValues: {
genders: [{ value: '남자' }],
},
});
이런 값을 Controller 컴포넌트로 관리하려면 어떻게 해야할까?
⚠️ 여기서 주의할 점!
typescript 로useFieldArray를 사용할 경우, 배열 값은 위와 같이{value: string}[]타입으로 지정& 선언 되어야 한다.
Controller 컴포넌트로 배열 값 관리해보기Controller 메서드로 배열을 관리하려면 꽤나 힘들다.
<form onSubmit={handleSubmit(submitForm)}>
<Controller
name={'genders'}
control={control}
render={({ field }) => (
<>
{field.value.map((item, index) => (
<Controller
name={`genders.${index}`}
control={control}
render={({ field: { onChange, value } }) => (
<RadioGroup onChange={onChange} value={value}>
<Radio value='M'>남자</Radio>
<Radio value='F'>여자</Radio>
</RadioGroup>
)}
/>
))}
</>
)}
/>
<Button type='submit'>제출</Button>
</form>
상당히 보기 힘든 코드가 된다 🥹
genders 를 모두 관리하는 Controller 를 선언한다.useController 로 한 겹 벗겨내기아니면 useController 를 이용하여 아래와 같이 래핑 하나를 벗겨낼 수도 있다.
const { field } = useController({ name: 'genders', control });
<form onSubmit={handleSubmit(submitForm)}>
{field.value.map((item, index) => (
<Controller
name={`genders.${index}`}
control={control}
render={({ field: { onChange, value } }) => (
<RadioGroup onChange={onChange} value={value.value}>
<Radio value='M'>남자</Radio>
<Radio value='F'>여자</Radio>
</RadioGroup>
)}
/>
))}
<Button type='submit'>제출</Button>
</form>
그럼 한번 더 useController 를 사용해서 Controller 를 아예 다 없애버리면 되지 않나? 라고 생각할 수도 있다.
물론 가능하지만 이 경우 하위 컴포넌트를 따로 만들어야 한다. 그래야 index 와 control 객체를 받아서 useController 에 전달할 수 있기 때문이다.
const RadioComponent = ({index, control}) => {
const { field } = useController({
name: `genders.${index}`,
control: control
})
return (
<RadioGroup onChange={onChange} value={value}>
<Radio value='M'>남자</Radio>
<Radio value='F'>여자</Radio>
</RadioGroup>
)
}
useFieldArray이런 식으로 관리하기 싫은 경우, 바로 useFieldArray 를 사용할 수 있다.
// field 가 아니라 fields 임에 주의하자!
const { fields } = useFieldArray({
name: 'genders',
control
})
return (
{
<form onSubmit={handleSubmit(submitForm)}>
{fields.map((item, index) => (
<Controller
key={item.id}
name={`genders.${index}`}
control={control}
render={({ field: { onChange, value } }) => (
<RadioGroup onChange={onChange} value={value.value}>
<Radio value='M'>남자</Radio>
<Radio value='F'>여자</Radio>
</RadioGroup>
)}
/>
))}
<Button type='submit'>제출</Button>
</form>
}
)
useFieldArray 는 자동으로 배열에 대한 id 를 만들어준다는 장점이 있어서, map 함수로 컴포넌트를 생성할 때 필요한 key 값을 고민하지 않아도 된다.
흠… 그런데 useController 를 사용했을 때와 별반 다를 게 없는데, 꼭 사용해야할까?
useFieldArray 의 반환값사실 useFieldArray 는 fields 값 말고도 input 값들을 관리하기 위해 다양한 메서드를 제공한다.
const {
fields,
append,
prepend,
remove,
swap,
move,
insert } = useFieldArray({
control,
name: 'genders'
});
append()
prepend()
remove(index)
insert(index)
swap(from ,to)
move(from, to)
update(index, obj)
replace(obj[])
fields 를 대체한다useFormContext 는 Context API 와 유사하다. 만일 하위의 하위의…. 최종 하위 컴포넌트가 있다면, 저 멀리 useForm 의 메서드를 모두 보내주는 건 한계가 있을 것이다.
이때 FormProvider 로 값을 내려주고, useFormContext 로 값을 읽어오기만 하면 어디서든 쉽게 useForm 의 메서드에 접근할 수 있다.
FormProviderimport { useForm, FormProvider, useFormContext } from 'react-hook-form';
export default function App() {
const methods = useForm();
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(console.log('submit!')}>
<NestedInput />
<input type='submit' />
</form>
</FormProvider>
);
}
useFormContextfunction NestedInput() {
const { register } = useFormContext();
return <input {...register('test')} />;
}