React-hook-form 사용 이유와 정리

수연·2024년 2월 27일

study

목록 보기
5/8

1. 왜 React-hook-form 이 필요한가?

많은 input 은 관리하기가 힘들다

로그인 페이지나 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 의 해결방안

react-hook-form 은 이러한 문제를 uncontrolled 방식으로 해결한다. 쉽게 말하자면 상태로 값을 관리하는 것이 아닌 input 의 ref 객체를 참조하여 거기서 값을 가져오는 것이다.

2. 가장 기본적인, useForm 훅

폼 전체를 쉽게 관리하기 위한, react-hook-form 의 주춧돌 훅이다.

useForm의 아주 많은 반환 값

const {
	register,
	watch,
	formState,
	handleSubmit,
	reset,
	getValues,
	// ... 아주 많은 메서드를 가지고 있다
} = useForm();

이렇게 반환되는 값 뿐만 아니라 useForm 자체에도 많은 프로퍼티를 넘겨줄 수 있다.

useForm의 아주 많은 프로퍼티

useForm({
	defaultValues,
	values,
	errors,
	mode
	// ... 역시 많은 프로퍼티
})

많은 프로퍼티들이 있으나 그걸 모두 번역하기보단 form 을 관리하기 위해 필수적인 값들만 다뤄보려고 한다.

2-1. useForm의 프로퍼티

이벤트 설정을 하는 mode

유효성 검사를 언제 실행할 것인지 설정할 수 있다. (기본은 onSubmit으로 submit이벤트가 발생할 때 유효성 검사를 진행한다.)

onChange | onBlur | onSubmit | onTouched | all = 'onSubmit'

useForm({
	mode: 'onSubmit' // onSubmit 이벤트 발생시 폼 제출
})

onChange

  • input의 값이 변경될 때마다 유효성 검사를 한다. (사실 이러면 state로 관리한느 것과 바를 바 없는 렌더링 성능을 보여준다.)

onBlur

  • blur 이벤트가 발생할 때 (input의 포커스를 잃을 때)유효성 검사가 발생한다

onTouched

  • 처음 blur 이벤트가 발생할 때 트리거되며, 이후론 change 이벤트가 발생할 때마다 매번 트리거된다.

onSubmit

  • submit 이벤트가 발생할 때 유효성 검사를 진행한다.

form 의 기본값을 설정하자. defaultValues

defaultValues 는 form 을 이루고 있는 기본 값을 설정한다. 이때 주의해야할 점은 undefined 등의 값을 넣어주면 안된다.

useForm({
	defaultValues: {
		firstName: '',
		lastNAme: ''
	}
})

외부 값에 의존하는 values

defaultValues 가 내부적으로 input 을 통해 관리되는 값들이라면, values 는 외부요소에 의해 업데이트 되는 값이다.

const data = fetch('/api');

useForm({
	values: data // data 가 업데이트 되면 업데이트
})

외부 상태에 의존하는 errors

values 와 마찬가지로 폼이 서버나 외부의 상태에 의해 변경되는 경우 사용하며, error 가 발생한 경우를 위해 쓰인다.

const { data, errors } = fetch('/api');

useForm({
	values: data,
	errors
})

업데이트 시 리셋을 어떻게 할까? resetOptions

values 나 defaultValues 에 업데이트가 일어나면 내부적으로 reset API 가 동작한다. 이 때 리셋과 관련된 옵션을 설정한다.

keepDirtyValues: boolean
keepErrors: boolean

useForm({
  values,
  resetOptions: {
    keepDirtyValues: true,
    keepErrors: true, 
  },
})

keepDirtyValues

  • reset 으로 인한 업데이트가 일어나도 유저가 변경한 값을 유지할 것인지 설정한다.

keepErrors

  • input 에 있는 에러를 유지할 것인지 설정한다.

2-2. useForm의 반환값

현재 상태를 알려주는 formState

formState 는 폼의 상태와 관련된 상태를 제공해준다.

isSubmitting | isSubmitted | isSubmitSuccessful | isLoading
isValid | isValidating
isDirty | dirtyFields
touchedFields | defaultValues | submitCount
errors

const { formState } = useForm();
const { isSubmitting, isSubmitted, isValid } = formState;

또 헷갈리는 상태에 대해서만 설명하자면…

isSubmitted 와 isSubmitSuccessful 의 차이

  • 둘다 submit 이 완료되었음을 불리언 값으로 알려준다.
  • 다만 isSubmitSuccessful 은 폼을 제출하는 도중 런타임에러가 발생하면 false 를 반환한다.

isSubmitting 과 isLoading 의 차이

  • 현재 폼을 제출하고 있는 상태를 불리언 값으로 반환한다.
  • isLoading 는 async 를 기본값으로 설정한 경우가 해당된다.

isValidating

  • 현재 유효성 검사가 진행되고 있는지 여부를 불리언 값으로 반환한다.

isDirty

  • defaultValues 로 설정한 값과 비교하여 같은지를 불리언 값으로 반환한다.

dirtyFields

  • 사용자가 어떤 필드를 변경했는지 객체 형태로 반환한다.

touchedFields

  • 사용자가 해당 필드와 한번이라도 상호작용을 한 필드를 모아 객체로 반환한다.

useForm 에서 제일 중요한 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

  • 해당 input 값이 필수로 존재해야하는지 설정한다.

disabled

  • 해당 input 을 조작할 수 없도록 설정한다.

maxLength & minLength

  • input 의 type 이 text 인 경우 글자의 최대길이와 최소 길이를 설정한다.

min & max

  • input 의 type 이 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

  • input 의 값을 설정한다.

valueAsNumber & valueAsDate

  • 각각 숫자, 날짜 형식만을 값으로 받을 수 있다. 유효하지 않은 숫자, 날짜의 경우 NaN 과 invalid Date 를 반환한다.

setValueAs

  • 콜백함수를 실행한 뒤 value 값을 반환한다.
  • valueAsNumber 과 valueAsDate 를 무시한다.
  • defaultValues 나 defaultValue 의 값은 변화시키지 않으며 text 만 변환가능하다.
    setValueAs: (value) => value.trim();

onChange & onBlur

  • onChange 이벤트와 onBlur 시 이벤트를 설정한다.

shouldUnRegister

  • 언마운트시 unregistered 됨과 동시에 값을 삭제할지 지정한다.

deps

  • 유효성 검증을 실행할 때 의존성을 추가한다.

3. 간단한 예제

아래와 같이 이뤄진 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>
	);
}

4. useController & Controller

왜 해당 메서드가 필요할까?

react-hook-form 은 uncontrolled 기반의 라이브러리이다. 직접 UI 를 만드는 경우라면 원하는 대로 동작하겠지만 외부의 컴포넌트(chakra, MUI 등)는 state 를 기반으로 동작하는 controlled 방식의 컴포넌트일 수 있다.

이 경우 useForm 만 사용해서는 원하는 방식으로 동작하지 않을 수 있는데, useControllerController 가 중간다리 역할을 해줄 수 있다.

chakra 의 Radio 컴포넌트

예시로 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 컴포넌트다.

4-1. 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

  • useForm 으로 관리할 input 의 이름을 지정한다.

control

  • useForm 훅에서 받은 control 객체를 등록한다.
    const { control } = useForm();

render

  • 렌더링하고자 하는 컴포넌트를 집어넣는다. 이때 다음과 같은 형식으로 작성해야 한다.
    <Controller
    	render={({ field: {} }) => (
    		<component />
    	)}
    />

render 의 field 프로퍼티

field 프로퍼티는 흡사 register 메서드와 비슷한 값들을 가지고 있다.

field: {
	onChange,
	onBlur,
	value,
	disabled,
	// ... 등등
}

여기서 value 를 주의해야 하는데, 여기서 value 는...

<input value={value} />

위와 같은 용도로 사용되는 것이 아니라, react-hook-form 에서 값의 변화를 감지하는 훅인 useWatch 를 사용하여 변경되는 시점의 필드 값을 가져온다.

그리고 만약 useForm에서 유효성 검사시점을 위해 mode 프로퍼티를 onBlur 로 설정해주었다면 onBlur를 넘겨줘야 제대로 유효성검사를 할 수 있다.

4-2. useController

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 문서에선 useControllerController 보다 상위 문서에서 설명하고 있다.

5. useFieldArray

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 를 선언한다.
  • 최상위 Controller 에서 genders[0] 과 genders[1] 을 관리하는 컴포넌트를 또 다시 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 를 아예 다 없애버리면 되지 않나? 라고 생각할 수도 있다.

물론 가능하지만 이 경우 하위 컴포넌트를 따로 만들어야 한다. 그래야 indexcontrol 객체를 받아서 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 의 반환값

사실 useFieldArrayfields 값 말고도 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 를 대체한다

6. useFormContext

useFormContext 는 Context API 와 유사하다. 만일 하위의 하위의…. 최종 하위 컴포넌트가 있다면, 저 멀리 useForm 의 메서드를 모두 보내주는 건 한계가 있을 것이다.

이때 FormProvider 로 값을 내려주고, useFormContext 로 값을 읽어오기만 하면 어디서든 쉽게 useForm 의 메서드에 접근할 수 있다.

값을 내려보내주는 FormProvider

import { 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>
	);
}

값을 읽어오는 useFormContext

function NestedInput() {
	const { register } = useFormContext(); 

	return <input {...register('test')} />;
}

0개의 댓글