resister와 Controller의 주요 차이는 Uncontrolled Components와 Controlled Components의 사용과 관련이 있다.
register는 주로 Uncontrolled Components(비제어 컴포넌트)를 관리하기 위해 사용된다.
<input {...register('fieldName')}>
형태로 간단히 사용할 수 있다.Controller는 Controlled Components를 관리하기 위해 사용된다.
<Controller {...controlProps} />
형태로 사용되며, React의 상태 기반으로 input을 제어하여 필요할 때마다 re-rendering을 수행한다.import React from 'react';
import { useForm, SubmitHandler } from 'react-hook-form';
import { emailReg, passwordReg } from '@/util/Regex';
type FormValues = {
email: string;
password: string;
};
const Form = () => {
const {
register,
handleSubmit,
formState: { isValid, errors },
} = useForm<FormValues>({
defaultValues: {
email: '',
password: '',
},
});
const onSubmit: SubmitHandler<FormValues> = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<label>
이메일
<input
{...register('email', {
required: '이메일을 입력해 주세요.',
pattern: {
value: emailReg,
message: '올바른 이메일 형식을 입력해 주세요.',
},
})}
/>
{errors.email && <span>{errors.email.message}</span>}
</label>
<label>
비밀번호
<input
{...register('password', {
required: '비밀번호를 입력해 주세요.',
minLength: {
value: passwordReg,
message: '비밀번호는 최소 8자 이상이어야 합니다.',
},
})}
/>
{errors.password && <span>{errors.password.message}</span>}
</label>
<button disabled={!isValid} type='submit'>
제출
</button>
</form>
);
};
export default Form;
register 함수를 사용하여 각 입력 필드를 form에 등록한 형태의 Form 컴포넌트이다.
register
: register 함수는 첫 번째 인자로 field name을 받고, 두 번째 인자로 validation rules
를 객체 형태로 받는다.다음으로는 위 코드를 변경해 Controller
를 사용하여 input
, label
, 에러메시지
를 하나의 컴포넌트로 관리하고 제어할 수 있도록 만들어 보자.
각각 Controller
컴포넌트와 useController
커스텀 훅 버전으로 만들어 비교해 보았다.
// Form.tsx
import React from 'react';
import { useForm, SubmitHandler } from 'react-hook-form';
import InputWithLabel from '@/components/common/InputWithLabel';
import { emailReg, passwordReg } from '@/util/Regex';
type FormValues = {
email: string;
password: string;
};
const Form = () => {
const {
control,
handleSubmit,
formState: { isValid },
} = useForm<FormValues>({
defaultValues: {
email: '',
password: '',
},
});
const onSubmit: SubmitHandler<FormValues> = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<InputWithLabel
control={control}
name='email'
label='이메일'
rules={{
required: '이메일을 입력해 주세요.',
pattern: {
value: emailReg,
message: '올바른 이메일 형식을 입력해 주세요.',
},
}}
/>
<InputWithLabel
control={control}
name='password'
label='비밀번호'
rules={{
required: '비밀번호를 입력해 주세요.',
minLength: {
value: passwordReg,
message: '비밀번호는 최소 8자 이상이어야 합니다.',
},
}}
defaultValue=''
/>
<button disabled={!isValid} type='submit'>
제출
</button>
</form>
);
};
export default Form;
const {
control,
handleSubmit,
formState: { isValid },
} = useForm<FormValues>({
defaultValues: {
email: '',
password: '',
},
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<InputWithLabel
control={control}
name='email'
label='이메일'
rules={{
required: '이메일을 입력해 주세요.',
pattern: {
value: emailReg,
message: '올바른 이메일 형식을 입력해 주세요.',
},
}}
/>
<InputWithLabel
control={control}
name='password'
label='비밀번호'
rules={{
required: '비밀번호를 입력해 주세요.',
minLength: {
value: passwordReg,
message: '비밀번호는 최소 8자 이상이어야 합니다.',
},
}}
/>
<button disabled={!isValid} type='submit'>
제출
</button>
</form>
);
InputWithLabel 컴포넌트는 이메일과 비밀번호 필드를 위해 각각 사용되며, 각 필드의 유효성 검사 규칙을 props로 전달한다.
control
: useForm hook에서 반환된 control 객체로, form의 상태와 form field를 제어하는데 필요한 메서드들을 포함하고 있다. name
: form field의 이름rules
: form field의 유효성 검사 규칙을 설정하는 객체로, 여기서는 'required'와 'minLength' 규칙을 설정했다.import { InputHTMLAttributes } from 'react';
import {
useController,
FieldValues,
FieldPath,
UseControllerProps,
} from 'react-hook-form';
interface TextInputProps<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> extends UseControllerProps<TFieldValues, TName>,
Omit<InputHTMLAttributes<HTMLInputElement>, 'name' | 'defaultValue'> {
label: string;
}
export const InputWithLabel = ({
control,
name,
rules,
defaultValue,
label,
...rest
}: TextInputProps) => {
const {
field: { ref, ...inputProps },
fieldState: { error },
} = useController({
name,
control,
rules,
defaultValue,
});
return (
<label>
{label}
<input {...inputProps} {...rest} ref={ref} />
{error && <span>{error.message}</span>}
</label>
);
};
interface TextInputProps<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> extends UseControllerProps<TFieldValues, TName>,
Omit<InputHTMLAttributes<HTMLInputElement>, 'name' | 'defaultValue'> {
label: string;
}
UseControllerProps
와 InputHTMLAttributes<HTMLInputElement>
를 extend해 Props Interface를 생성했다. 이렇게 설정하면 form field를 제어하는 데 필요한 속성들과 입력 필드에 필요한 속성을 모두 가질 수 있게 된다. 해당 코드에서는 InputHTMLAttributes<HTMLInputElement>
의 속성들 중 name
과 defaultValue
를 제외하고 나머지 속성들을 사용하며, label
과 같이 이외 임의로 지정하는 속성을 추가했다.
const {
field: { ref, ...inputProps },
fieldState: { error },
} = useController({
name,
control,
rules,
defaultValue,
});
useController를 사용해 각 필드의 DOM ref와 field의 inputProps(onChange
, onBlur
, value
등)를 반환받는다. control
을 포함해 Form 컴포넌트에서 받아온 props들은 useController의 인자로 넣어준다.
export const InputWithLabel = ({
control,
name,
rules,
defaultValue,
label,
...rest
}: TextInputProps) => {
const {
field: { ref, ...inputProps },
fieldState: { error },
} = useController({
name,
control,
rules,
defaultValue,
});
return (
<label>
{label}
<input {...inputProps} {...rest} ref={ref} />
{error && <span>{error.message}</span>}
</label>
);
};
마지막으로 label
과 input
요소, 그리고 error
메시지를 렌더링한다. input
요소는 inputProps
와 rest
props를 받고, ref
는 useController에서 반환된 ref를 사용한다.
⚠️
{...inputProps}(field의 기능들)
와{...rest}(상위에서 전달 받은 props)
- input 태그에 전달 시 두 props 전달의 순서가 코드의 영향을 미칠 수 있다?<input {...rest} {...inputProps} ref={ref} />
최근 진행중인 실무 프로젝트에서
{...rest} {...inputProps}
순서와 같이 inputProps 순서를 뒤로 지정한 상태에서 겪은 문제이다.
상위에서disabled
라는 임의의 props를 만들어서 {...rest}로 가져와 넘겨 주었는데 이게input
에만 적용이 되지 않아 disabled는 자꾸 undefined가 담기는 것이었다. (해당 컴포넌트에서는 잘 찍히는 상태)
해결 방법은 간단하게도{...inputProps} {...rest}
와 같이 순서를 바꾸는 것이었다.
field
의inputProps
종류 중 내가 임의로 만들어 가져온 props와 이름이 동일한 속성(혹은 메서드와 같은 기능)이 존재할 경우 해당field
의inputProps
가 적용될 수 있으니 주의해 사용하면 좋을 것 같다.
InputWithLabel을 Controller 컴포넌트로 만들면 다음과 같다.
import { InputHTMLAttributes } from 'react';
import { Controller, Control, FieldValues, FieldPath } from 'react-hook-form';
interface TextInputProps<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> extends Omit<InputHTMLAttributes<HTMLInputElement>, 'name' | 'defaultValue'> {
control: Control<TFieldValues>;
name: TName;
label: string;
}
export const InputWithLabel = ({
control,
name,
label,
...rest
}: TextInputProps) => {
return (
<label>
{label}
<Controller
control={control}
name={name}
render={({ field, fieldState: { error } }) => (
<>
<input {...field} {...rest} />
{error && <span>{error.message}</span>}
</>
)}
/>
</label>
);
};
Controller 컴포넌트는 기본적으로 내부에서 useController
를 호출하고, 필요한 props를 자식 컴포넌트에게 전달하는 형태이다.
Controller 컴포넌트의 render
prop이 현재 필드의 상태와 함수들을 담은 field 객체와 에러 상태를 담은 fieldState 객체를 인자로 받는 함수를 요구하게 되고, 이 함수가 입력 필드를 렌더링해서 필요한 경우 에러 메시지를 보여준다.
useController를 직접 사용하는 것에 비해 조금 더 간단하고 명시적으로 컴포넌트를 작성할 수 있는 것이 특징이다.