개인 프로젝트를 진행하며 form 구현을 위해 react-hook-form을 이용하였다. 개발을 하던 중 input을 반복적으로 사용하여 공통 컴포넌트를 만들기 위해 공부를 하던 중, react-hook-form의 useController hook을 이용하면 공통 input 컴포넌트를 만들 수 있음을 알게되었다.
input element에는 여러가지 type이 있는데 useController을 사용할 때 그 모양새가 조금씩 다르다. 대표적인 세 가지 type을 구현한 코드를 여기서 정리하려고 한다.
// common/Input.tsx
type TControl<T extends FieldValues> = {
control: Control<T>;
name: FieldPath<T>;
rules?: Omit<
RegisterOptions<T>,
'valueAsNumber' | 'valueAsDate' | 'setValueAs' | 'disabled'
>;
icons?: JSX.Element;
placeholder: string;
type: string;
errorMessage: string;
};
function Input({
icons,
control,
name,
rules,
placeholder,
type,
errorMessage,
}: TControl<any>) {
const {
field: { value, onChange, onBlur },
} = useController({ name, rules, control });
const [isError, setIsError] = useState(true);
useEffect(() => {
if (errorMessage === undefined) {
setIsError(false);
} else {
setIsError(true);
}
}, [errorMessage]);
return (
<Container iconExist={!!icons} isError={isError}>
<input
// value가 undefined이면 input 에러가 발생하므로
value={value || ''}
onChange={onChange}
onBlur={onBlur}
placeholder={placeholder}
type={type}
/>
<IconWrapper>{icons}</IconWrapper>
<p>{errorMessage}</p>
</Container>
);
}
export default Input;
interface IForm {
email: string;
}
function Form() {
const {
handleSubmit,
formState: { errors },
setError,
control,
} = useForm<IForm>();
return (
<Container>
<form onSubmit={handleSubmit(onSubmit)}>
<InputWrapper>
{/* Email */}
<Input
name="email"
control={control}
rules={{
required: '이메일을 입력하세요',
pattern: {
value:
/^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*.[a-zA-Z]{2,3}$/i,
message: '이메일 형식이 아닙니다.',
},
}}
placeholder="이메일"
type="text"
icons={<Email />}
errorMessage={errors.email?.message as string}
/>
</InputWrapper>
</form>
</Container>
);
}
export default Form;
// common/Selector.tsx
type TControl<T extends FieldValues> = {
control: Control<T>;
name: FieldPath<T>;
rules?: Omit<
RegisterOptions<T>,
'valueAsNumber' | 'valueAsDate' | 'setValueAs' | 'disabled'
>;
icons?: JSX.Element;
options?: string[];
disabledOptions?: string[];
errorMessage: string;
lable?: string;
};
function Selector({
icons,
control,
name,
rules,
options = [],
disabledOptions = [],
errorMessage,
lable,
}: TControl<any>) {
const {
field: { value, onChange },
} = useController({ name, rules, control });
return (
<Container iconExist={!!icons}>
{lable && <span>{lable}</span>}
<select value={value || ''} onChange={onChange}>
{disabledOptions.map((option, index) => (
<option key={index} value={option} disabled>
{option}
</option>
))}
{options.map((option, index) => (
<option key={index} value={option}>
{option}
</option>
))}
</select>
<IconWrapper>{icons}</IconWrapper>
<p>{errorMessage}</p>
</Container>
);
}
export default Selector;
interface IForm {
month: string;
}
function Form() {
const {
handleSubmit,
formState: { errors },
setError,
control,
} = useForm<IForm>();
return (
<Container>
<form onSubmit={handleSubmit(onSubmit)}>
<SelectWrapper>
<Selector
icons={<SelectDown />}
name="month"
control={control}
rules={{ required: '선택해주세요' }}
options={monthList}
errorMessage={errors.month?.message as string}
/>
</SelectWrapper>
</form>
</Container>
);
}
export default Form;
개인적으로 제일 까다로웠다.
Radio는 여러 input이 모여 한 그룹을 형성하는 것이 일반적이므로 RadioGroup을 공통 컴포넌트로 만들었다.
위 두 유형과는 달리 onChange에 e.target.value로 값을 넘겨줘야했다. 안그러면 undefined가 나왔는데 이유는 아직 모르겠음..
type TControl<T extends FieldValues> = {
control: Control<T>;
name: FieldPath<T>;
rules?: Omit<
RegisterOptions<T>,
'valueAsNumber' | 'valueAsDate' | 'setValueAs' | 'disabled'
>;
label?: string;
errorMessage?: string;
options?: { label: string; value: any; description: string }[];
};
function RadioGroup({
control,
name,
rules,
label,
options,
}: TControl<any>) {
const { field } = useController({ name, rules, control });
const [checked, setChecked] = useState(false);
return (
<Conitaner>
<p className="radio-label">{label}</p>
<div className="radio-list-wrapper">
{options?.map((option, index) => (
<label key={index}>
<input
type="radio"
checked={field.value === option.value}
onChange={(e) => {
field.onChange(e.target.value);
setChecked(true);
}}
value={option.value}
/>
<span>
{option.label}
<RadioDescription>{option.description}</RadioDescription>
</span>
</label>
))}
</div>
{!checked && (
<Warning>
<WarningIcon />
<p>옵션을 선택해주세요.</p>
</Warning>
)}
</Conitaner>
);
}
export default React.memo(RadioGroup);
interface IForm extends FieldValues {
largeBuildingType: string;
buildingType: string;
radioType: string;
}
function Form() {
const [largeRoom, setLargeRoom] = useRecoilState(roomState);
const {
control,
formState: { errors },
watch,
} = useForm<IForm>();
};
const onSubmit = () => {
/* .. */
}
return (
<Container>
<form>
<RadioWrapper>
<RadioGroup
name="radioType"
control={control}
label="숙소 유형을 선택해주세요."
options={roomTypeRadioOptions}
/>
</RadioWrapper>
<RegisterRoomFooter
prevLink="/"
nextLink="/room/register/bedrooms"
onSubmit={onSubmit}
/>
</form>
</Container>
);
}
export default Form;
사실상 이 글을 쓴 메인 목적이다.
radio 유형에서 form에 handleSubmit을 등록하지 않고 Footer에 onSubmit 함수를 넘겨주고 있다.
원래는 form에 handelSubmit을 작성하였고, Footer에 버튼을 만들었으므로 클릭을하면 form에 등록된 이벤트가 실행될 줄 알았는데 아니었다.
react-hook-form 공식 사이트에서 handleSubmit에 대한 문서를 보니 다음과 같이 적혀있었다.
// It can be invoked remotely as well
handleSubmit(onSubmit)();
즉 굳이 form에만 handleSubmit을 등록할 필요 없고, 다른 컴포넌트에서도 handleSubmit을 등록할 수 있다.
// Footer.tsx
interface IProps {
prevLink?: string;
nextLink?: string;
onSubmit: (data: any) => void;
}
function RegisterRoomFooter({ prevLink, nextLink, onSubmit }: IProps) {
const { handleSubmit } = useForm();
return (
<Container>
<Link className="back" href={prevLink || ''}>
뒤로
</Link>
<Link href={nextLink || ''}>
<button
type="submit"
onClick={() => {
// 즉시실행함수 형태로 써야함!
handleSubmit(onSubmit)();
}}
>
계속
</button>
</Link>
</Container>
);
}
export default RegisterRoomFooter;
만약 Footer의 상위 컴포넌트에서 두 개 이상의 form이 있다면 하위 컴포넌트인 Footer에서 handleSubmit을 호출하면 그 두개의 form을 구별할 수 있을까..?
라디오를 어떻게 관리해야할지 막막했는데 도움이 됐습니다.
감사합니다.