MUI와 react-hook-form을 기반으로 공통 컴포넌트를 만드는 작업이 생각보다 간단하지 않았다. 🤔 그래서 해당 사항을 정리 해보고자 한다.
react-hook-form을 사용 해보았다면 사실 가장 처음 사용하게 되고, 익숙하게 사용하게 되는 사항이 register를 통해 input을 사용하는 것일 것이다. 아무래도 관련된 예시들이 많고 공식문서의 Quick start도 register로 되어 있기 때문이다. 하지만 react-hook-form이 비제어 컴포넌트 방식인 만큼 보통 제어 컴포넌트 방식을 사용하는 UI 라이브러리인 MUI와 접목시키기 애매한 부분이 있다. 따라서 react-hook-form에서는 이를 위해서 Controller라는 것을 제공하고 있다.
들어가기에 앞서 controller를 hook으로 사용 가능한 useController를 사용해서 진행하고자 한다. 이 custom hook은 react-hook-form에서 제공하고 있으며, 공식 문서에서도 나와 있지만, reusable한 controlled input을 만드는데 유용하다.
예시를 먼저 살펴보면 Input 컴포넌트의 props로 FormValues type을 전달 해주는 것을 볼 수 있다. 즉 TypeScript로 해당 컴포넌트를 구현하기 위해서는 다음과 같이 구성하여야 한다.
import { UseControllerProps } from "react-hook-form";
type FormValues = {
test: string;
};
const Input = (props: UseControllerProps<FormValues>) => {
// components.....
};
하지만 이렇게 구성하면 사실 재사용성이 있다고 하기 힘들다.
매번 form에 따라서 각각 Input 컴포넌트를 만들고 FormValues를 전달 하여야 하는데 이는 결국 재사용을 거의 못하는 것이기 때문이다.
이를 해결하기 위해서는 타입스크립트의 제너릭을 활용하여야 한다.
그러기 위해서는 일단 UseControllerProps가 어떻게 선언 되어있는지 확인할 필요성이 있다.
공식문서에 Type과 관련된 페이지가 있기는 하지만 아직 업데이트가 되지 않은듯 하다.
// 선언 파일을 통해 확인한 UseControllerProps type UseControllerProps<TFieldValues extends FieldValues = FieldValues, TName extends Path<TFieldValues> = Path<TFieldValues>>
필요한 타입을 알아 내었으니 이제 해당 하는 type들을 import 해준다
import {
useController,
FieldValues,
FieldPath,
UseControllerProps,
} from "react-hook-form";
그 후 다음과 같이 제너릭을 활용하여 공통 컴포넌트를 만들어 준다.
interface MuiProps {
textFieldProps?: TextFieldProps;
}
const Input = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
textFieldProps, // textField를 위한 prop들, mui에서 import 해온다.
...props
}: MuiProps & UseControllerProps<TFieldValues, TName>) => {
const {
field,
fieldState: { error },
} = useController(props);
return (
<TextField
{...textFieldProps}
{...field}
error={!!error}
helperText={!!error && error.message}
/>
);
};
위 코드를 보면 알 수 있지만 useController를 활용하면 직접적으로 Controller로 감싸는 것 보다 코드의 가독성이 더 좋아진다. 또한 register 대신 위와 같이 controller를 활용하면 rules를 제외한 error에 대한 핸들링도 굳이 form을 사용하는 컴포넌트에서 작성하지 않아도 되어 간편하다.
사실 컴포넌트를 만드는 과정이 끝나면 사용하는 방법은 간단하다.
따라서 별도의 설명없이 해당하는 코드를 보아도 이해 할 수 있다. (storybook으로 테스트를 했음)
// Input.stories.tsx
interface FormValue {
test?: string;
}
export const Default: ComponentStory<typeof Input> = () => {
const { control, handleSubmit } = useForm<FormValue>({
defaultValues: {
test: "",
},
});
const onSubmit = (data: FormValue) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Input
name="test"
control={control}
rules={{ required: "필수!" }}
textFieldProps={{
label: "Test",
}}
/>
<button type="submit">submit</button>
</form>
);
};
validation도 정상적으로 작동하고 submit 함수도 잘 실행되는 것을 볼 수 있다.
도움이 많이 되었습니다. 감사합니다.