mui 와 react-hook-form

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 컴포넌트 입니다.

이제 천천히 우리의 InputTextreact-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 에 값을 제공한다면 반드시 useFormdefaultValue 를 넣어줘서 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 도 들어있기 때문에 컴포넌트 작업을 할 때 편리하게 필드를 관리할 수 있습니다. 그 외에 fieldStateformState 에는 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;
mui-select-component
구현된 Select 컴포넌트

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 가 됩니다.

react-hook-form-radio
구현된 Radio 컴포넌트

InputText, Select, Radio 외에 여러가지 Input 필드들이 있지만 대표적으로 세 가지 컴포넌트를 구현해봤습니다. 다른 Input 필드들도 useController 을 활용한다면 손쉽게 react-hook-form 과 연동하여 컴포넌트를 구현할 수 있답니다.

마무리하며

사실 MUI 와 react-hook-form 을 바인딩해서 제공해주는 라이브러리가 있긴 합니다. 해당 라이브러리의 코드를 살펴봤을때 해당 라이브러리는 Controller API 를 사용해 두 라이브러리를 바인딩 하고 있습니다. 하지만 MUIreact-hook-form 을 같이 사용하기 위해 굳이 또 다른 라이브러리의 의존성을 추가해야 할 필요가 있을까 하는 고민을 해보았고, 라이브러리 사용이 아닌 직접 구현을 해보았습니다.

react-hook-form 은 유명세를 타고 있는 다른 라이브러리들 (용도는 다르지만 react-query 나 redux 와 같은..) 에 비해서 한국어로 된 레퍼런스나 글들이 적습니다. 이번 시리즈가 한국의 react-hook-form 생태계에 좋은 기여로 남았으면 좋겠습니다.

이번 시리즈의 마지막 포스팅은 react-hook-form 의 사용한 5달 동안 본인이 느꼈던 고민과 사소한 트러블 슈팅으로 마무리 할 예정입니다. 구현된 코드를 살펴보고 싶으시다면 아래 Github 를 참조해주세요! 감사합니다!

Github >

시리즈 글 살펴보기

  1. react-hook-form 을 활용해 효과적으로 폼 관리하기
  2. react-hook-form 다양하게 사용하기
강동희
Developer

오픈소스컨설팅 Playce Dev Team 소속으로 오늘을 바탕으로 내일이 더 기대되는 프론트엔드 개발자 강동희입니다. 빠르게 변화하는 기술들에 관심이 많으며 지식 공유에 큰 가치를 두고 있습니다.

Leave a Reply

Your email address will not be published. Required fields are marked *