react-hook-form with MUI
안녕하세요. Playce Dev 팀에서 Frontend 개발을 하고 있는 강동희 입니다. 이번 포스팅은 react-hook-form 시리즈의 두 번째 포스팅으로 프론트엔드 파트의 react 프로젝트에 react-hook-form 을 도입하며, 공부 했고 고민했던 과정들을 여러분들께 공유하는 시간을 갖도록 하겠습니다. 저번 포스팅에서는 먼저 제어 컴포넌트로 간단한 form 을 만들어봤고, 이 form 에 react-hook-form 을 적용해보며 핵심 hook 인 useForm
에 대해서 살펴봤습니다.
이번 포스팅에서는 react-hook-form 과 MUI 에서 제공하는 Input 컴포넌트를 연동해서 공통 컴포넌트화를 시켜 사용하는 방법에 대해서 알아보도록 하겠습니다. 먼저 MUI 의 컴포넌트를 사용하지 않고 react-hook-form 이 관리하는 공통 input 컴포넌트를 구현해본 후 MUI 의 Input, Select, Radio 컴포넌트에 react-hook-form 을 연동하는 공통 컴포넌트를 구현해 볼 것입니다.
🥹 바쁜 현대인들을 위해, 이 글의 핵심은 useController
훅 입니다.
MUI 를 사용하지 않는 react-hook-form 컴포넌트
먼저 MUI 를 사용하지 않는 InputText
컴포넌트를 구현하도록 하겠습니다. 기본적으로 공통 컴포넌트를 사용하지 않고 form 을 구현하면 아래와 같이 구현할 수 있습니다.
<input {...register("name", {option})}/>
<input {...register("name2", {option})}/>
<input {...register("name3", {option})}/>
<input {...register("name4", {option})}/>
위 코드의 input
태그들은 재사용이 가능한 공통 컴포넌트로 만들 수 있습니다. 만약 디자인이 입혀진 input
필드를 이용하려면 더더욱 공통 컴포넌트로 만들어서 사용해야 할 것입니다.
function InputText() {
return <input className="input"/>
};
공통 컴포넌트로 구현을 하고 form 에서 기존의 방식으로 register
함수를 통해 react-hook-form 과 연동하려고 하자 에러가 발생합니다.
<InputText {...register("name", {required: "반드시 입력해주세요."})}/>
에러가 발생하는게 당연합니다! 우린 props
를 통해서 받을수 있는게 아무것도 없거든요. input
은 html 의 기본 태그이고, 우리가 만든 InputText
는 우리가 만든 react 컴포넌트 입니다.
이제 천천히 우리의 InputText
에 react-hook-form 을 입혀보도록 하겠습니다.
useController
Let me introduce useController
react-hook-form 에는 useController
이라는 훅이 있습니다. 이 훅을 활용해서 우리의 컴포넌트에 react-hook-form 을 입힐 수 있습니다!
😅useController
훅 이외에Controller
라는 API 도 있습니다. 하지만 개인적으로useController
가 더욱 구현하기 편했기 때문에useController
을 사용하여 구현해보도록 하겠습니다.
import { useController } from "react-hook-form";
const {
field: { value, onChange },
fieldState: { isDirty, isTouched, error },
formState,
} = useController(params);
useController
의 모습입니다. 먼저 간단하게 훅에 들어가는 파라미터인 params 에 대해서 살펴보도록 하겠습니다.
export type UseControllerProps<TFieldValues extends FieldValues = FieldValues, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>> = {
name: TName;
rules?: Omit<RegisterOptions<TFieldValues, TName>, 'valueAsNumber' | 'valueAsDate' | 'setValueAs' | 'disabled'>;
shouldUnregister?: boolean;
defaultValue?: FieldPathValue<TFieldValues, TName>;
control?: Control<TFieldValues>;
};
useController
훅에서 파라미터로 받는 객체의 타입입니다. 해당 필드의 이름인 name
, validation
을 위한 rules
, 그리고 react-hook-form 의 일부로 제어할 수 있도록 control
객체를 넣어줍니다. 그 외에 defaultValue
는 기본값을 제공하는데 사용하는데, defaultValue
에 값을 제공한다면 반드시 useForm
에 defaultValue
를 넣어줘서 undefined
을 만들지 않도록 해야 합니다.
파라미터에 대해서 살펴봤다면 리턴 해주는 객체에 대해서 살펴볼 시간입니다.
export type UseControllerReturn<TFieldValues extends FieldValues = FieldValues, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>> = {
field: ControllerRenderProps<TFieldValues, TName>;
formState: UseFormStateReturn<TFieldValues>;
fieldState: ControllerFieldState;
};
리턴해주는 객체는 간단합니다. 먼저 Input 에 직접적으로 넣어줄 value
, onChange
가 들어있는 field, form 의 상태를 담고있는 formState
, 해당 필드의 상태를 담고있는 fieldState
입니다. field 객체에는 onBlur
, ref
도 들어있기 때문에 컴포넌트 작업을 할 때 편리하게 필드를 관리할 수 있습니다. 그 외에 fieldState
와 formState
에는 isDirty
, error
, isTouched
등이 들어있습니다.
field
객체의 프로퍼티인 value
, onChange
를 꺼내서 input
태그에 연결해줍니다.
function InputText() {
const {field: {value, onChange}} = useController({name, rules, control});
return <input className="input" value={value} onChange={onChange} />
};
useController
의 자세한 내용이 궁금하시다면 공식 문서를 참고해주세요! 공식문서 바로가기>
Let’s start to use useController!
useController
을 어떻게 사용하는지는 이제 알겠습니다! 그럼 name
, rules
, control
의 값을 우리의 컴포넌트에 주입시켜보도록 하겠습니다.
import {
Control,
FieldPath,
FieldValues,
RegisterOptions,
} from "react-hook-form";
export type TControl<T extends FieldValues> = {
control: Control<T>;
name: FieldPath<T>;
rules?: Omit<
RegisterOptions<T>,
"valueAsNumber" | "valueAsDate" | "setValueAs" | "disabled"
>;
};
타입을 새로 만듭니다. 위 타입은 react-hook-form 에서 제공해주는 타입이 아니며, useController
을 사용하는 컴포넌트를 위해서 타입을 만들어줬습니다.
해당 타입을 props
에 타이핑 시켜주고, props
에서 꺼내서 useController
의 매개변수 객체에 넣어줍니다.
function InputText({control, name, rules}: TControl) {
const {field: {value, onChange}} = useController({name, rules, control});
return <input className="input" value={value} onChange={onChange} />
};
이제 공통 컴포넌트의 작업이 끝났습니다! 그럼 form 에서 해당 컴포넌트를 Import 해서 사용해 봅시다.
function Form() {
const { control } = useForm({ defaultValue: { name:"name" }});
return <InputText name="name" control={control} rules={{required:"입력해주세요"}} />
}
useForm
이 리턴해주는 객체에서 control
객체를 꺼내서 InputText 의 control props 에 넣어줍니다. rules
에 들어가는 객체는 기존에 register
에 들어가는 option 의 validation
객체와 같습니다.
이제 공통 컴포넌트에 react-hook-form
을 입히는 과정이 끝났습니다! 참 간단하죠?
MUI 를 활용한 react-hook-form 컴포넌트
자체 디자인 시스템을 사용하지 않는 프로젝트라면 보통 MUI, Ant design 과 같은 Common UI Component Library 를 사용하고는 합니다. 디자인이 잘 되어있을 뿐더러, 개발하는 과정과 시간을 절약해줄 수 있고, 버전 관리도 잘 해주고 있기 때문입니다.
이번 포스팅에서는 간단하게 MUI 의 TextField, Select, Radio 컴포넌트를 활용해 폼에서 사용되는 InputText, Select, RadioGroup 를 구현해보겠습니다!
구현하기 전에 MUI 를 설치!
yarn add @mui/material @emotion/react @emotion/styled
npm install @mui/material @emotion/react @emotion/styled
MUI 를 사용하려면 emotion 을 설치해야 합니다. styled-component 를 사용하여 설치할수도 있으니 공식 문서의 설치 탭을 살펴보시기 바랍니다!
InputText
먼저 InputText 입니다. InputText 는 위의 useController
을 입히는 과정과 유사하기에 구현체 코드만 남겨 놓도록 하겠습니다.
import React from "react";
import { TextField, TextFieldProps } from "@mui/material";
import { TControl } from "common/type";
import { FieldValues, useController } from "react-hook-form";
type TProps<T extends FieldValues> = TextFieldProps & TControl<T>;
function MInputText<T extends FieldValues>({
name,
rules,
control,
...props
}: TProps<T>) {
const {
field: { value, onChange },
fieldState: { isDirty, isTouched, error },
} = useController({
name,
rules,
control,
});
return (
<TextField
value={value}
onChange={onChange}
InputProps={{
sx: {
border: `1px solid ${error ? "red" : "green"}`, //간단한 에러처리
},
}}
{...props}
/>
);
}
export default MInputText;
Select
Select 컴포넌트를 구현해보도록 하겠습니다. 먼저 Select 컴포넌트에서 사용할 list 의 타입을 만들어줍니다.
export interface ISelectItem {
label: ReactNode;
value: string | number;
selected?: boolean;
disabled?: boolean;
hidden?: boolean;
}
우리는 이 타입의 배열을 MUI 의 MenuList
에 담아서 사용할 것입니다.
MUI Select 에 넣어줄 props
를 그대로 사용해야 하기 때문에 MUI 의 SelectProps
, useController
를 위한 타입, 그리고 우리의 컴포넌트만을 위한 props
타입을 union
타입으로 묶어줍니다.
type TProps<T extends FieldValues> = Omit<
SelectProps,
"onChange" | "placeholder"
> &
CustomSelectProps<T> &
TControl<T>;
기존에 useController
을 사용했던 방법대로 props
에서 객체를 꺼내 바인딩 해주고, MUI 의 Select 컴포넌트를 활용해 컴포넌트를 구현합니다.
import React, { ReactNode } from "react";
import {
Select,
SelectProps,
SelectChangeEvent,
MenuItem,
} from "@mui/material";
import { TControl } from "common/type";
import { FieldValues, useController } from "react-hook-form";
export interface ISelectItem {
label: ReactNode;
value: string | number;
selected?: boolean;
disabled?: boolean;
hidden?: boolean;
}
// 만약 props 가 더 필요하다면 아래 정의하면 됩니다.
type CustomSelectProps<T> = {
selectList: ISelectItem[];
placeholder: string;
onChange?: (event: SelectChangeEvent<T>) => void;
};
type TProps<T extends FieldValues> = Omit<
SelectProps,
"onChange" | "placeholder"
> &
CustomSelectProps<T> &
TControl<T>;
function MSelect<T extends FieldValues>(props: TProps<T>) {
const {
name,
rules,
control,
selectList,
placeholder,
onChange: propsOnChange,
} = props;
const {
field: { value, onChange, onBlur },
} = useController({
name,
rules,
control,
});
const handleChange = (event: SelectChangeEvent<T>) => {
onChange(event);
if (propsOnChange) {
propsOnChange(event);
}
};
const renderValue = () =>
value
? selectList.find((item) => item.value === value)?.label
: placeholder;
return (
<Select
value={value}
renderValue={renderValue}
onChange={handleChange}
onBlur={onBlur}
sx={{
width: "220px",
padding: "8px",
"& .MuiSelect-outlined": { padding: 0 },
}}
>
{selectList.map(({ label, value, disabled }, index) => (
<MenuItem key={index} value={value} disabled={disabled ?? false}>
{label}
</MenuItem>
))}
</Select>
);
}
export default MSelect;
Radio
Radio 컴포넌트도 Select 컴포넌트와 크게 다르지 않습니다. 다만 Select 컴포넌트에서 list 를 위해 타입을 만들었다면, Radio 에서는 MUI 의 FormControlLabelProps
를 활용해 group 을 만들어서 사용합니다.
export type TRadioGroup = Omit<FormControlLabelProps, "control">;
// 만약 props 가 더 필요하다면 아래 정의하면 됩니다.
type CustomSelectProps = {
group: TRadioGroup[];
size?: "medium" | "small";
onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
};
FormControlLabelProps
타입에서 control
프로퍼티를 제외하여 RadioGroup
타입을 만들었고, 이를 group 이라는 프로퍼티를 통해 배열로 받습니다.
해당 group 은 MUI 의 FormControlLabel
을 통해서 사용합니다.
import React, { ChangeEvent } from "react";
import {
RadioGroupProps,
FormControlLabelProps,
FormControl,
FormControlLabel,
Radio,
RadioGroup,
} from "@mui/material";
import { TControl } from "common/type";
import { FieldValues, useController } from "react-hook-form";
export type TRadioGroup = Omit<FormControlLabelProps, "control">;
// 만약 props 가 더 필요하다면 아래 정의하면 됩니다.
type CustomSelectProps = {
group: TRadioGroup[];
size?: "medium" | "small";
onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
};
type TProps<T extends FieldValues> = Omit<RadioGroupProps, "onChange"> &
CustomSelectProps &
TControl<T>;
function MRadio<T extends FieldValues>(props: TProps<T>) {
const {
name,
rules,
control,
group,
size = "medium",
onChange: propsOnChange,
} = props;
const {
field: { value, onChange },
} = useController({
name,
rules,
control,
});
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
onChange(event);
if (propsOnChange) {
propsOnChange(event);
}
};
return (
<FormControl>
<RadioGroup row name={name} value={value} onChange={handleChange}>
{group.map(({ value: radioValue, disabled, label }, index) => (
<FormControlLabel
key={index}
value={radioValue}
label={label}
control={
<Radio size={size} value={radioValue} disabled={disabled} />
}
/>
))}
</RadioGroup>
</FormControl>
);
}
export default MRadio;
group 속의 하나의 객체가 하나의 Radio 가 되어서 Group 을 만들어주고, 이 Group 이 Radio Field 가 됩니다.
InputText, Select, Radio 외에 여러가지 Input 필드들이 있지만 대표적으로 세 가지 컴포넌트를 구현해봤습니다. 다른 Input 필드들도 useController
을 활용한다면 손쉽게 react-hook-form
과 연동하여 컴포넌트를 구현할 수 있답니다.
마무리하며
사실 MUI 와 react-hook-form
을 바인딩해서 제공해주는 라이브러리가 있긴 합니다. 해당 라이브러리의 코드를 살펴봤을때 해당 라이브러리는 Controller
API 를 사용해 두 라이브러리를 바인딩 하고 있습니다. 하지만 MUI
와 react-hook-form
을 같이 사용하기 위해 굳이 또 다른 라이브러리의 의존성을 추가해야 할 필요가 있을까 하는 고민을 해보았고, 라이브러리 사용이 아닌 직접 구현을 해보았습니다.
react-hook-form
은 유명세를 타고 있는 다른 라이브러리들 (용도는 다르지만 react-query 나 redux 와 같은..) 에 비해서 한국어로 된 레퍼런스나 글들이 적습니다. 이번 시리즈가 한국의 react-hook-form
생태계에 좋은 기여로 남았으면 좋겠습니다.
이번 시리즈의 마지막 포스팅은 react-hook-form
의 사용한 5달 동안 본인이 느꼈던 고민과 사소한 트러블 슈팅으로 마무리 할 예정입니다. 구현된 코드를 살펴보고 싶으시다면 아래 Github 를 참조해주세요! 감사합니다!