1. React Hook Form
: 간편한 리액트 폼 관리 라이브러리
1. 장점
1) 비제어 컴포넌트로 동작한다.
react hook form은 기본적으로 비제어 컴포넌트 방식으로 구현되어 있어, 렌더링 이슈를 해결한다. 비제어 컴포넌트 방식의 장점은 그대로 살리면서, 제어 컴포넌트에서만 다룰 수 있는 실시간 유효성 검사, 실시간 동기화 등의 API를 제공하여 실시간 유효성 검사 및 동기화를 가능하게 해준다.
💡 제어 컴포넌트 vs 비제어 컴포넌트?
input, textarea, select와 같은 폼 엘리먼트는 일반적으로 사용자의 입력을 기반으로 자신의 state를 관리하고 업데이트한다.
💡 React Hook Form은 어떻게 이러한 문제들을 해결했을까?
React Hook Form은 어떻게 렌더링 이슈를 해결했을까?
React Hook Form은 어떻게 동기화 이슈는 어떻게 해결했을까?
2) 여러 가지 유틸과 코드 단순화
React Hook Form에서 자체적으로 제공하는 유틸로 대부분의 유틸 함수를 대체할 수 있다. 예를 들어 reset, setValue, dirty 등이 있다.
3) class validator 지원
class-validator를 이용해서, 각각의 필드에 대해 손쉽게 유효성 검사할 수 있다.
export default class ProjectModel {
@Length(1, 100, { message: ERROR_MESSAGE_PROJECT_NAME })
projectName = '';
...
}
백엔드에서도 이를 사용하여 백엔드와 프론트엔드 사이 유효성 검사 로직 싱크를 맞출 수 있다.
4) 코드의 일관성 확보
React Hook Form은 어느정도 사용하는 방법이 정해져 있기 때문에, 코드의 일관성 확보를 위한 팀원 간의 커뮤니케이션 비용을 최소화할 수 있다.
2. React Hook Form 잘 사용하기
1. Controller이용한 컴포넌트 제어
register함수에는 ref객체도 포함되어 전달이 된다. 내부에서 ref를 자체적으로 사용하는 컴포넌트가 있다면, Controller를 이용하여 컴포넌트를 제어할 수 있다.
<Controller
name={`career.companies.${index}.employmentStatus`}
render={({ field }) => (
<Dropdown label="재직 여부" autoSelect options={employmentStatusOptions} {...field} />
)}
/>
2. useFieldArray를 이용한 배열 관리
const { control, register } = useForm();
const { fields, append, prepend, remove, swap, move, insert } = useFieldArray({
control, // control props comes from useForm (optional: if you are using FormContext)
name: "test", // unique name for your Field Array
});
3. dev tool을 이용한 디버깅
3. 각 기능들
1. useForm
: 폼을 쉽게 사용할 수 있는 커스텀 훅
- props
- mode : 제출하기 전의 유효성 전략
- reValidateMode : 제출 이후 유효성 전략
- defaultValues : 폼 기본값
- values : 폼 값을 업데이트하는 활성화된 값
- resetOptions : 새로운 폼 값을 업데이트하는 동안 폼 상태를 초기화하는 옵션
- criteriaMode : 모든 유효성 에러 를 항상 혹은 한 번만 보여준다.
- 그 외 : shouldFocusError, delayError, shouldUseNativeValidation, shouldUnregister, resolve, context
- return
-
register : input이나 select요소를 등록하고 유효성 규칙을 적용한다. 유효성 규칙은 모두 HTML 표준을 기준으로 하고 있고 또한 커스텀 유효성 방법도 허락한다.
const { onChange, onBlur, name, ref } = register('firstName'); <input onChange={onChange} // assign onChange event onBlur={onBlur} // assign onBlur event name={name} // assign name prop ref={ref} // assign ref prop /> // same as above <input {...register('firstName')} />
-
- control : 컴포넌트를 등록하기 위한 메서드를 포함한 객체
import { useForm, Controller } from "react-hook-form";
function App() {
const { control } = useForm();
return (
<Controller
render={({ field }) => <input {...field} />}
name="firstName"
control={control}
defaultValue=""
/>
);
}
2) useController
: Controller에 전원을 공급해주는 훅
- Controller와 같은 props와 메서드를 공유한다.
- 재사용 가능한 Controlled input을 만드는 데 유용하다.
const { field: input } = useController({ name: 'test' })
const { field: checkbox } = useController({ name: 'test1' })
<input {...input} />
<input {...checkbox} />
3) useFormContext
: 폼 context에 접근할 수 있게 해주는 커스텀 훅
- 구조를 깊게 감쌀 때 사용한다. 즉, props drilling을 해결할 때 사용한다.
- useForm + Context API라고 보면 된다.
const methods = useForm()
<FormProvider {...methods} /> // all the useForm return props
const methods = useFormContext() // retrieve those props
4) useWatch
: watch API와 비슷하게 행동하지만, 앱의 더 나은 성능을 위해서 커스텀 훅에서 고립된 리렌더링을 하게 해준다.
import React from "react";
import { useForm, useWatch } from "react-hook-form";
function Child({ control }) {
const firstName = useWatch({
control,
name: "firstName",
});
return <p>Watch: {firstName}</p>;
}
function App() {
const { register, control } = useForm({
firstName: "test"
});
return (
<form>
<input {...register("firstName")} />
<Child control={control} />
</form>
);
}
5) useFormState
: 각 폼 상태를 구독하고, 커스텀 훅 레벨에서 리렌더링으로부터 고립되게 해준다.
- 구독한 상태에 대한 스코프를 가지고 있기에, 다른 useFormState나 useForm에 영향을 끼치지 않는다.
import * as React from "react";
import { useForm, useFormState } from "react-hook-form";
export default function App() {
const { register, handleSubmit, control } = useForm({
defaultValues: {
firstName: "firstName"
}
});
const { dirtyFields } = useFormState({
control
});
const onSubmit = (data) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("firstName")} placeholder="First Name" />
{dirtyFields.firstName && <p>Field is dirty.</p>}
<input type="submit" />
</form>
);
}
6) useFieldArray
: 동적인 폼에서 배열 필드를 다루기 위한 커스텀 훅
- 더 나은 사용자 경험과 성능을 제공한다.
function FieldArray() {
const { control, register } = useForm();
const { fields, append, prepend, remove, swap, move, insert } = useFieldArray({
control, // control props comes from useForm (optional: if you are using FormContext)
name: "test", // unique name for your Field Array
});
return (
{fields.map((field, index) => (
<input
key={field.id} // important to include key with field's id
{...register(`test.${index}.value`)}
/>
))}
);
}
다른 라이브러리와 비교
1) npm trend
react-hook-form이 압도적으로 1등이고, 다음 2순위로는 formik이 있다.
2) Formik vs React Hook Form
- 성능 비교 : React Hook Form이 Formik보다 약 1.5배 가까이 빠르다.
- React Hook Form : 3800ms
- Formik : 5800ms
- Star 수
- React Hook Form : 44.5k, 업데이트 1주일 전
- Formik : 33.7k, 업데이트 3개월 전
- 코드 : React Hook Form이 더 깔끔하다.
Formik 예시
import React from 'react';
import { useFormik } from 'formik';
const SignupForm = () => {
// Pass the useFormik() hook initial form values and a submit function that will
// be called when the form is submitted
const formik = useFormik({
initialValues: {
email: '',
},
onSubmit: values => {
alert(JSON.stringify(values, null, 2));
},
});
return (
<form onSubmit={formik.handleSubmit}>
<label htmlFor="email">Email Address</label>
<input
id="email"
name="email"
type="email"
onChange={formik.handleChange}
value={formik.values.email}
/>
<button type="submit">Submit</button>
</form>
);
};
- 그 외 : 렌더링 이슈 해결 & 여러가지 유틸과 코드 단순화 & class validator 지원
자세한 내용 : 1. 장점
4. React Hook Form 없이 상태 관리하기
1. setState를 이용하여 제어 컴포넌트 만들기
-
code
import { useState } from 'react'; export default function ControlledForm() { const [name, setName] = useState(''); const [phone, setPhone] = useState(''); const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => { const { name, value } = e.target; if (name === 'name') { setName(value); } if (name === 'phone') { setPhone(value); } }; const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); console.log(name, phone); }; return ( <form onSubmit={handleSubmit}> <div> <p>이름</p> <input type="text" name="name" value={name} onChange={handleInput} /> </div> <div> <p>전화번호</p> <input type="text" name="phone" value={phone} onChange={handleInput} /> </div> <button>제출</button> </form> ); } -
결과
위와 같이, input에 값을 입력할 때마다 리렌더링이 일어남을 확인할 수 있다. 왜냐하면, input값이 변경된다 → onChange이벤트함수가 실행된다 → setState를 이용하여 상태값을 변경한다 → 컴포넌트가 리렌더링이 일어난다.
이 컴포넌트만 리렌더링이 일어날 뿐 아니라, 이 컴포넌트를 자식으로 가지고 있는 모든 상위 컴포넌트에 리렌더링이 일어날 것이다.
2. useRef를 이용하여 비제어 컴포넌트 만들기
-
코드
import { useRef } from 'react'; export default function UnControlledForm() { const nameRef = useRef<HTMLInputElement>(null); const phoneRef = useRef<HTMLInputElement>(null); const handleSubmit = () => { if (nameRef.current && phoneRef.current) { console.log(nameRef.current.value, phoneRef.current.value); } }; return ( <form onSubmit={handleSubmit}> <div> <p>이름</p> <input type="text" name="name" ref={nameRef} /> </div> <div> <p>전화번호</p> <input type="text" name="phone" ref={phoneRef} /> </div> <button>제출</button> </form> ); } -
결과
useRef는 돔에 대해 접근을 할 수 있게 해주는 훅이다. setState를 이용한 상태값 변경이 이뤄지지 않았으므로 input에 값을 입력해도 input외에는 리렌더링이 일어나지 않는 것을 확인할 수 있다.
3. React Hook Form을 이용하여 비제어 컴포넌트 만들기
-
코드
export default function ReactHookForm() { const { register, handleSubmit } = useForm<FormData>({ defaultValues }); const onSubmit = handleSubmit((formData) => { console.log(formData); }); return ( <form onSubmit={onSubmit}> <div> <p>이름</p> <input type="text" {...register('name')} /> </div> <div> <p>전화번호</p> <input type="text" {...register('phone')} /> </div> <button>제출</button> </form> ); } -
결과
위처럼, 비제어 컴포넌트로 입력값을 관리할 수가 있다.
그럼, ref를 사용하는 것과 같지 않느냐라고 할 수 있다.
이는 에러를 체크하거나 실시간으로 무언가 체크할 때 유용하다.
-
코드
export default function ReactHookForm() { const { register, formState } = useForm<FormData>({ defaultValues, mode: 'onChange', }); return ( <form onSubmit={onSubmit}> <div> <p>이름</p> <input type="text" {...register('name', { minLength: { value: 10, message: '10자 이상 입력해주세요.', }, })} /> </div> {formState.errors.name && <p>{formState.errors.name.message}</p>} </form> ); }minLength로, 최소 10글자를 설정해주었다.
-
결과
0글자, 10글자 넘을 때 한 번, 10글자보다 줄어들 때 한 번, 0글자일 때 한 번 리렌더링이 일어남을 확인할 수 있다.
❓ 어떻게 구현한걸까?
비제어를 위해 내부적으로 useRef를 이용했을 것이다. useRef로 계속 체크를 하다가(이 때는 렌더링이 일어나지 않는다), 내가 지정한 최소 숫자인 10에 도달했을 때 렌더링을 일으킨 것일까?
자세한 내용 : 1. React Hook Form은 어떻게 렌더링 이슈를 해결했을까?
결론
setState를 이용하여 제어 컴포넌트로 입력을 관리할 경우, 매 입력마다 리렌더링이 일어났다. 성능상 매우 좋지 않다.
useRef를 이용하여 비제어 컴포넌트로 입력을 관리할 경우, 리렌더링이 일어나지 않았다.
React Hook Form을 사용하여 비제어 컴포넌트로 입력을 관리할 경우 또한 리렌더링이 일어나지 않았다. 또한, 특정 조건에서 렌더링이 일어나게 하는 것이 가능했다. 예를 들어, 10글자 이상 등 요구하는 조건에 성립하지 않는다면 메시지를 보여주고 싶을 때 유용할 듯하다.또한, useForm() 하나의 훅을 선언함으로써 여러 상태를 한 번에 관리할 수 있다는 점은, 많은 상태를 관리할 때 특히 강점을 발휘할 것이라 생각한다.
팩트
-
watch()로 항상 체크한다면, input은 제어 컴포넌트가 된다.
const { register, handleSubmit, watch } = useForm<FormData>({ defaultValues }); console.log(watch());watch()를 제출할 때만 사용한다면 비제어 컴포넌트를 유지하지만, 항상 체크한다면, 제어 컴포넌트가 되어, 리렌더링이 일어난다.
5. 궁금한 점
1. React Hook Form은 어떻게 렌더링 이슈를 해결했을까?
// src/useForm.ts
const _values = React.useRef<typeof props.values>();
useRef로 input의 상태를 관리한다. 그리고
// src/useForm.ts
const [formState, updateFormState] = React.useState<FormState<TFieldValues>>({
isDirty: false,
isValidating: false,
isLoading: isFunction(props.defaultValues),
isSubmitted: false,
isSubmitting: false,
isSubmitSuccessful: false,
isValid: false,
submitCount: 0,
dirtyFields: {},
touchedFields: {},
validatingFields: {},
errors: props.errors || {},
disabled: props.disabled || false,
defaultValues: isFunction(props.defaultValues)
? undefined
: props.defaultValues,
});
폼 상태는 useState로 관리함을 알 수 있다.
왜냐하면, 폼 상태의 변경은 리렌더링을 일으켜야 하지만, input값의 변경은 리렌더링을 일으키지 않아야 하기 때문이다.
그렇다. React Hook Form은 useRef를 이용하여 비제어 방식으로 폼을 다루는 것이다.
폼 상태 핸들링(로딩/에러/dirty 등)
-
폼 상태에 대한 상태는 useState로 제어 컴포넌트 방식으로 관리한다.
// src/useForm.ts const [formState, updateFormState] = React.useState<FormState<TFieldValues>>({ isDirty: false, isValidating: false, isLoading: isFunction(props.defaultValues), isSubmitted: false, isSubmitting: false, isSubmitSuccessful: false, isValid: false, submitCount: 0, dirtyFields: {}, touchedFields: {}, validatingFields: {}, errors: props.errors || {}, disabled: props.disabled || false, defaultValues: isFunction(props.defaultValues) ? undefined : props.defaultValues, }); -
렌더링이 필요하다면, 폼 상태를 업데이트 한다.
// src/useForm.ts useSubscribe({ subject: control._subjects.state, next: ( value: Partial<FormState<TFieldValues>> & { name?: InternalFieldName }, ) => { if ( shouldRenderFormState( value, control._proxyFormState, control._updateFormState, true, ) ) { updateFormState({ ...control._formState }); } }, });여기서 중요한 것은, 렌더링이 필요하다면(shouldRenderFormState)이다.
shouldRenderFormState를 이용하여 렌더링이 필요한 지 체크한다.
// src/logic/shouldRenderFormState.ts export default <T extends FieldValues, K extends ReadFormState>( formStateData: Partial<FormState<T>> & { name?: InternalFieldName }, _proxyFormState: K, updateFormState: Control<T>['_updateFormState'], isRoot?: boolean, ) => { updateFormState(formStateData); const { name, ...formState } = formStateData; return ( isEmptyObject(formState) || Object.keys(formState).length >= Object.keys(_proxyFormState).length || Object.keys(formState).find( (key) => _proxyFormState[key as keyof ReadFormState] === (!isRoot || VALIDATION_MODE.all), ) ); };-
updateFormState로 ref객체로 관리하는
control._updateFormState를 업데이트 한다.updateFormState(formStateData);
-
그 다음으로, 아래 사항들을 체크한다.
- formState가 비었는가. formState가 비었다는 것은, 아직 formState가 상태에 등록이 안되었다는 것을 의미한다.
isEmptyObject(formState)
- formState의 키값의 길이가 _proxyFormState의 키값의 길이보다 같거나 큰 지 체크한다.
Object.keys(formState).length >= Object.keys(_proxyFormState).length
- formState의 키 중 하나라도 _proxyFormState에서 해당 키의 값이 (!isRoot || VALIDATION_MODE.all)과 일치하는 지 체크한다.
Object.keys(formState).find(
(key) =>
_proxyFormState[key as keyof ReadFormState] ===
(!isRoot || VALIDATION_MODE.all),
)
만약, 위의 사항 중 하나라도 속한다면, 상태값이 변경된 것임을 의미하므로 이제 상태값을 업데이트한다.
updateFormState({ ...control._formState });
정리하자면, 에러나 값이 유효한 지, 로딩 중인지, dirty한 지 등 상태를 관리하기 위해 formState를 상태로 관리한다. 그리고, 이 상태를 바로 변경하지 않고, shouldRenderFormState함수를 이용하여 상태가 실제로 변경되었는 지 체크한다. 이 때, 비제어 방식의 _formControl의 값을 먼저 변경하고, 실제로 값이 변경되었는 지 체크한 후 변경을 한다.
정리
정리하자면, React Hook Form은 input값을 useRef를 이용하여 비제어 컴포넌트 방식으로 useRef 사용한다. 그리고, 폼 상태에 대한 관리는 제어 방식으로 state를 사용한다. 하지만, _formControl이라는 ref객체를 하나 더 두어, 이 객체를 먼저 변경한 후, 실제로 변경이 이뤄졌는 지 체크 후 state에 반영한다.
2. React Hook Form은 어떻게 동기화 이슈는 어떻게 해결했을까?
React Hook Form에서 데이터 동기화는 FormProvider를 이용하여 이뤄진다.
export default function App() {
const methods = useForm()
const onSubmit = (data) => console.log(data)
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<NestedInput />
<input type="submit" />
</form>
</FormProvider>
)
}
function NestedInput() {
const { register } = useFormContext() // retrieve all hook methods
return <input {...register("test")} />
}
FormProvider로 감싸고, 안에서는 useFormContext를 이용하여 호출하는 방식이다. React의 Context API와 매우 유사함을 확인할 수 있다.
역시나 내부 로직도 동일하다.
// src/useFormContext.tsx
export const FormProvider = <
TFieldValues extends FieldValues,
TContext = any,
TTransformedValues extends FieldValues | undefined = undefined,
>(
props: FormProviderProps<TFieldValues, TContext, TTransformedValues>,
) => {
const { children, ...data } = props;
return (
<HookFormContext.Provider value={data as unknown as UseFormReturn}>
{children}
</HookFormContext.Provider>
);
};
FormProvider라는 컴포넌트도 선언해야 하기에, use-파일명임에도 .tsx확장자를 사용하는 것을 확인할 수 있다.
💡 as unknown as UseFormReturn
// src/useFormContext.tsx
const HookFormContext = React.createContext<UseFormReturn | null>(null);
export const useFormContext = <
TFieldValues extends FieldValues,
TContext = any,
TransformedValues extends FieldValues | undefined = undefined,
>(): UseFormReturn<TFieldValues, TContext, TransformedValues> =>
React.useContext(HookFormContext) as UseFormReturn<
TFieldValues,
TContext,
TransformedValues
>;
정리
정리하자면, FormContext는 성능 개선을 위한 컴포넌트 보다는, 사용자의 편의성을 위해 제공해주고, 타입 안정성을 보장해주는 컴포넌트라고 볼 수 있다.