기초적인 사용법에 대해서는 다루지 않습니다. 해당 사항을 찾으시는 경우 다른 레퍼런스나 공식문서를 참고 부탁드립니다
이전 포스팅에서 useController
를 활용하여 재사용성 있는 제어 컴포넌트를 만든적이 있다. 해당 제어 컴포넌트를 사용해서 DX
를 올릴 수 있었지만 여러 form
을 만들다 보니 다음과 같은 문제점에 직면하게 되었다.
첫째: form
별로 중복성 높은 코드가 자주 작성 되었다. 예를 들면, Input
별로 control
을 일일히 prop
으로 넘겨 주었어야 하는 등 유사한 코드를 중복해서 작성하는 일이 꽤 많이 생겼다
둘째: 관심사 분리가 제대로 되지 않아 가독성이 많이 저하되는 문제가 발생 하였다. 예를 들어 Input
컴포넌트에 일일히 control
prop을 전달하고, rules
를 정의하며 Input
컴포넌트의 내에서 에러를 핸들링 하다보니 Input 컴포넌트의 고유 관심사(?)라고 할 수 있는 단순 데이터 입력 측면 보다는 복잡해져서 가독성이 많이 저하 되었고 이는 결국 form이 복잡해 질수록 DX
를 저하시키는 요인이 되었다.
서론이 많이 길었지만 결국 개선을 해야 겠다고 마음먹은 요인은 정리하자면 가독성 개선
과 중복성 개선
이다
방법에 대해서 고민 하며 공식문서를 찾아보다 advanced usage
를 보고 아이디어를 얻게 되었다(공식문서).
즉 form 자체를 컴포넌트화 하여 child들에게 control을 넘겨주는 방식으로 빼어서 해당하는 form의 컨텍스트를 전달하는 로직을 분리하여 관심사를 Form 커스텀 컴포넌트
로 넘겨주는 방식이라고 할 수 있다
"use client"; // next.js(v13이상)에서 react이면 제거
import React, { DOMAttributes } from "react";
import { Controller } from "react-hook-form";
import type {
FieldPath,
FieldValues,
UseControllerProps,
} from "react-hook-form";
type TForm<
T extends FieldValues = FieldValues,
K extends FieldPath<T> = FieldPath<T>
> = {
onSubmit: DOMAttributes<HTMLFormElement>["onSubmit"];
ruleDefinitions?: Record<keyof T, UseControllerProps<T, K>["rules"]>;
control: UseControllerProps<T, K>["control"];
};
const Form = <
T extends FieldValues = FieldValues,
K extends FieldPath<T> = FieldPath<T>
>({
children,
onSubmit,
ruleDefinitions,
control,
...props // form(html tag)에 대한 속성들
}: React.PropsWithChildren<
TForm<T, K> & React.FormHTMLAttributes<HTMLFormElement>
>) => {
return (
<form onSubmit={onSubmit} {...props}>
{React.Children.map(children, (child) => {
// typeguard
if (!child) return null;
if (!React.isValidElement(child)) return null;
// props에 name 속성이 포함된 경우만 Controller
// 그 외에(ex: 버튼)는 child 렌더링
return child.props.name ? (
<Controller
control={control}
name={child.props.name}
rules={ruleDefinitions?.[child.props.name]}
render={({ field }) => {
return React.cloneElement(child, { ...field, ...child.props });
}}
/>
) : (
child
);
})}
</form>
);
};
export default Form;
이렇게 만든 위의 Form 컴포넌트는 child가 name prop(즉, 입력과 관련된 컴포넌트)인 경우 Controller 컴포넌트를 통해 렌더링 되며, 제출 버튼 같이 입력과 관계가 없어 Controller가 필요하지 않은 경우 해당 컴포넌트 그대로 렌더링 하게 된다.
Form을 만들고 나면 사용은 간단하다
<Form
control={control}
onSubmit={handleSubmit(onSubmit)}
ruleDefinitions={{ test: { required: "필수 데이터 입니다"} }}
>
<InputText name="test" />
<span>{errors.test?.message}</span>
<button type="submit">submit</button>
</Form>
이전과 비교해보면 Input 컴포넌트를 Controller로 감싸던가, 커스텀 컴포넌트로 따로 만들지 않아도 간단히 작성이 가능하며, react-hook-form
의 컨텍스트를 관리하는 관심사는 Form 컴포넌트에서 가져가 Input에 직접 작성하지 않아도 되어 보다 가독성이 올라갔다고 할 수 있다.
UseController
커스텀 훅을 사용하는 것도 나쁘지 않은 방법이다. 하지만 위와 같이 form 구성이 복잡해서 고통을 받는다면 이 포스트의 방법도 좋을 것이라 생각한다