😸

MUI v5 とReact Hook Form v7 を連携させる際の設計と実装例の紹介

2023/05/21に公開

現在のプロジェクトでMUIとReact Hook Form(以下RHF)を組み合わせてフォームを実装しています。しかし、フォームコンポーネントの設計に関して何もルールが整備されておらず、実装が複雑になっていました。そのため、メンテナンスがしづらかったり、汎用的に使いづらい状態でした。そこで、フォームコンポーネントの設計の見直しを行なった結果、いい感じの設計ができたので、今回はその設計と実装例を紹介したいと思います!

制御コンポーネントと非制御コンポーネント

Reactには制御コンポーネントと非制御コンポーネントの2つのタイプが存在します。制御コンポーネントは、フォームの入力値や状態をReactコンポーネント自体で管理します。一方、非制御コンポーネントは、フォームの入力値や状態をDOM要素自体が管理します。
RHFを使用する場合、通常はregisterを使用して非制御コンポーネントとして扱います。非制御コンポーネントとして扱うことで、フォームの値が変わった際も再レンダリングが走らず、パフォーマンスが向上します。
ただしMUIは制御コンポーネントに分類されます。そのため、MUIとRHFを連携する際は、RHFが外部の制御コンポーネントと連携するために提供しているControlleruseControllerを使用して連携することになります。
しかし、MUIでもTextFieldなどはregisterを使用して、非制御コンポーネントとして連携することができます。先述した通り、非制御コンポーネントで扱う方が再レンダリングの回数が減り、パフォーマンスも向上させることができます。
そのためRHFとMUIを連携させる際の方針として、非制御コンポーネントとして連携できるものは優先して非制御コンポーネントとして連携し、非制御コンポーネントとして連携が難しい場合は制御コンポーネントとして連携することにしました。
(制御・非制御のどちらとして連携するかによって、registerControllerを使い分けることになるので、コンポーネント全体で統一感を持たせるという目的で、制御コンポーネントで統一するのもありかなと思います。)

コンポーネントのレイヤー分類

フォームコンポーネントをViewコンポーネントとControllerコンポーネントの2つのレイヤーに分けて実装する設計方針にしています。
この設計方針はマナリンクさんの下記の記事をかなり参考にさせていただきました。
https://zenn.dev/manalink_dev/articles/manalink-react-hook-form-v7

Viewコンポーネント

見た目に関する関心のみを持つレイヤーのコンポーネントです。基本的にはMUIのコンポーネントをラップするだけです。サンプルではMUIのPropsを全て受け取れるようにしてますが、受け取れるPropsを絞ることで、呼び出し元で上書き可能な挙動をプロジェクト全体で制御できます。
また、bulletproof-reactを参考にして、FieldWrapperコンポーネントを定義して、ラベルやエラー文などの表示を共通化しています。
Storybookはこのレイヤーのコンポーネントに対してのみ作成します。レイヤーを分ける前はStorybookのファイル内でuseFormの呼び出しを行わないといけなかったのですが、レイヤーを分けることによってRHFへの依存がなくなったので、Storybookのファイル内でuseFormを呼び出す必要がなくなり、Storybookが作成しやすくなりました。

FieldWrapper.tsx
import Box from '@mui/material/Box';
import FormControl from '@mui/material/FormControl';
import Typography from '@mui/material/Typography';
import { PropsWithChildren } from 'react';

type FieldWrapperProps = PropsWithChildren<{
  label: string;
  required?: boolean;
  errorMessage?: string;
}>;

export type FieldWrapperPassThroughProps = Omit<FieldWrapperProps, 'children'>;

export const FieldWrapper: React.FC<FieldWrapperProps> = ({
  label,
  required,
  errorMessage,
  children,
}) => {
  return (
    <FormControl fullWidth error={!!errorMessage}>
      <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
        <Box sx={{ display: 'flex', gap: 0.5 }}>
          <Typography sx={{ fontSize: 20 }}>{label}</Typography>
          {required && (
            <Typography color="error" fontSize={12}>
              ※必須
            </Typography>
          )}
        </Box>
        {children}
        {errorMessage && (
          <Typography color="error" fontSize={12}>
            {errorMessage}
          </Typography>
        )}
      </Box>
    </FormControl>
  );
};
import MuiTextField, {
  TextFieldProps as MuiTextFieldProps,
} from '@mui/material/TextField';
import { FieldWrapper, FieldWrapperPassThroughProps } from './FieldWrapper';

export type TextFieldProps = {
  fieldWrapper: FieldWrapperPassThroughProps;
  muiTextField?: MuiTextFieldProps;
};

export const TextField: React.FC<TextFieldProps> = ({
  fieldWrapper,
  muiTextField,
}) => {
  return (
    <FieldWrapper {...fieldWrapper}>
      <MuiTextField {...muiTextField} error={!!fieldWrapper.errorMessage} />
    </FieldWrapper>
  );
};

Controllerコンポーネント

RHFとViewコンポーネントのつなぎこみを行うレイヤーのコンポーネントです。RHFとの連携に必要なパラメータをViewコンポーネントに渡す役割を担います。制御コンポーネントと非制御コンポーネントによって、実装が若干異なります。

非制御コンポーネントの場合

registerの返り値をPropsとして受け取り、そのままViewコンポーネントのPropsに渡します。非制御コンポーネントの場合はViewコンポーネントを直接呼び出して使用することもできます。ただ、非制御コンポーネントにもControllerコンポーネントを作成することで下記のようなメリットがあります。

  • フォームコンポーネントを使用する際は、制御コンポーネントと非制御コンポーネントの区別に関係なく、Controllerコンポーネントを呼び出すという統一のルールにできる
  • 非制御コンポーネントとの連携に必要なパラメータをControllerコンポーネントのPropsとして定義できるので、必要なパラメータを明確にできる
import {
  FieldPathByValue,
  FieldValues,
  UseFormRegisterReturn,
} from 'react-hook-form';

import { TextField, TextFieldProps } from './TextField';

type TextFieldControllerProps<TFieldValues extends FieldValues> = {
  registration: UseFormRegisterReturn<FieldPathByValue<TFieldValues, string>>;
  textField: TextFieldProps;
};

export const TextFieldController = <TFieldValues extends FieldValues>({
  registration,
  textField: { fieldWrapper, muiTextField },
}: TextFieldControllerProps<TFieldValues>): JSX.Element => {
  return (
    <TextField
      fieldWrapper={fieldWrapper}
      muiTextField={{
        ...muiTextField,
        ...registration,
      }}
    />
  );
};

制御コンポーネントの場合

useControllerのPropsをコンポーネントのPropsとして受け取り、コンポーネント内部でuseControllerを呼び出します。そしてuseControllerの返り値をViewコンポーネントの適切なPropsに渡します。

import {
  FieldPathByValue,
  FieldValues,
  useController,
  UseControllerProps,
} from 'react-hook-form';

import { Select, SelectProps } from './Select';

type SelectControllerProps<
  TFieldValues extends FieldValues,
  TValue extends number | string | '',
> = {
  controller: UseControllerProps<
    TFieldValues,
    FieldPathByValue<TFieldValues, TValue>
  >;
  select: SelectProps<TValue>;
};

export const SelectController = <
  TFieldValues extends FieldValues,
  TValue extends number | string | '' = number | '',
>({
  controller,
  select: { fieldWrapper, muiTextField, options },
}: SelectControllerProps<TFieldValues, TValue>): JSX.Element => {
  const {
    field,
    fieldState: { error },
  } = useController(controller);

  return (
    <Select
      fieldWrapper={{
        ...fieldWrapper,
        errorMessage: error?.message,
      }}
      muiTextField={{
        ...muiTextField,
        ...field,
      }}
      options={options}
    />
  );
};

型定義

FieldPathByValueの利用

ControllerコンポーネントのPropsに含まれるRHF関連の型定義には、FieldPathByValueを使用しています。

type TextFieldControllerProps<TFieldValues extends FieldValues> = {
  registration: UseFormRegisterReturn<FieldPathByValue<TFieldValues, string>>;
  textField: TextFieldProps;
};

type SelectControllerProps<
  TFieldValues extends FieldValues,
  TValue extends number | string | '',
> = {
  controller: UseControllerProps<
    TFieldValues,
    FieldPathByValue<TFieldValues, TValue>
  >;
  select: SelectProps<TValue>;
};

FieldPathByValueは、第一ジェネリクスに指定したフォームの型から、第二ジェネリクスに指定した型を値として持つフィールドパスを返す型定義です。

type Form = {
  name: string;
  age: number;
  email: string;
};

// FieldPathByValueを使用して、値がstring型のフィールドパスを取得する
type StringFieldPath = FieldPathByValue<Form, string>;
// 結果: "name" | "email"

// FieldPathByValueを使用して、値がnumber型のフィールドパスを取得する
type NumberFieldPath = FieldPathByValue<Form, number>;
// 結果: "age"

ControllerコンポーネントのPropsで使用しているUseFormRegisterReturnUseControllerPropsのジェネリクスにFieldPathByValueを使用した型を指定しています。そうすることで、そのフォームコンポーネントで扱う値の型を持つフィールド以外で使用しようとした場合は、コンパイルエラーが発生するので型安全に扱うことができます。

type Form = {
  name: string;
  age: number;
  email: string;
};

const App = () => {
  const { register } = useForm<Form>();

  return (
    <>
      {/* OK */}
      <TextFieldController<Form>
        registration={register('name')}
        textField={{ fieldWrapper: { label: 'TextField' } }}
      />
      {/* NG */}
      <TextFieldController<Form>
        // string以外の型のフィールドを指定している
        registration={register('age')}
        textField={{ fieldWrapper: { label: 'TextField' } }}
      />
    </>
  );
};

選択肢系の型定義

選択肢系のフォームコンポーネントでは、valueの型をジェネリクスで受け取るようにしています。選択肢のvalueに使用する値は、使用場面によってstringとnumberのどちらかを使い分けたいです。そのため、ジェネリクスで受け取ることで、途中で型変換の処理を挟むことなくなり、valueを扱いやすくしています。

export type Option<T> = { label: string; value: T };
export type Options<T> = Option<T>[];

export type SelectProps<T extends number | string = number> = {
  fieldWrapper: FieldWrapperPassThroughProps;
  muiTextField?: TextFieldProps;
  options: Options<T>;
};

実装例

上記の方針を踏まえたコンポーネントの実装をいくつか載せておきます。全体像は下記のリポジトリにpushしてあります。
https://github.com/KazukiHayase/RHF-MUI-sample

TextField
TextField.tsx
import MuiTextField, {
  TextFieldProps as MuiTextFieldProps,
} from '@mui/material/TextField';
import { FieldWrapper, FieldWrapperPassThroughProps } from './FieldWrapper';

export type TextFieldProps = {
  fieldWrapper: FieldWrapperPassThroughProps;
  muiTextField?: MuiTextFieldProps;
};

export const TextField: React.FC<TextFieldProps> = ({
  fieldWrapper,
  muiTextField,
}) => {
  return (
    <FieldWrapper {...fieldWrapper}>
      <MuiTextField {...muiTextField} error={!!fieldWrapper.errorMessage} />
    </FieldWrapper>
  );
};
TextFieldController.tsx
import {
  FieldPathByValue,
  FieldValues,
  UseFormRegisterReturn,
} from 'react-hook-form';

import { TextField, TextFieldProps } from './TextField';

type TextFieldControllerProps<TFieldValues extends FieldValues> = {
  registration: UseFormRegisterReturn<FieldPathByValue<TFieldValues, string>>;
  textField: TextFieldProps;
};

export const TextFieldController = <TFieldValues extends FieldValues>({
  registration,
  textField: { fieldWrapper, muiTextField },
}: TextFieldControllerProps<TFieldValues>): JSX.Element => {
  return (
    <TextField
      fieldWrapper={fieldWrapper}
      muiTextField={{
        ...muiTextField,
        ...registration,
      }}
    />
  );
};
Select
Select.tsx
import MenuItem from '@mui/material/MenuItem';
import TextField, { TextFieldProps } from '@mui/material/TextField';

import { FieldWrapper, FieldWrapperPassThroughProps } from './FieldWrapper';
import { Options } from './types';

export type SelectProps<T extends number | string = number> = {
  fieldWrapper: FieldWrapperPassThroughProps;
  muiTextField?: TextFieldProps;
  options: Options<T>;
};

export const Select = <T extends number | string = number>({
  fieldWrapper,
  muiTextField,
  options,
}: SelectProps<T>): JSX.Element => {
  return (
    <FieldWrapper {...fieldWrapper}>
      <TextField select error={!!fieldWrapper?.errorMessage} {...muiTextField}>
        {options.map(({ label, value }) => (
          <MenuItem key={value} value={value}>
            {label}
          </MenuItem>
        ))}
      </TextField>
    </FieldWrapper>
  );
};
SelectController.tsx
import {
  FieldPathByValue,
  FieldValues,
  useController,
  UseControllerProps,
} from 'react-hook-form';

import { Select, SelectProps } from './Select';

type SelectControllerProps<
  TFieldValues extends FieldValues,
  TValue extends number | string | '',
> = {
  controller: UseControllerProps<
    TFieldValues,
    FieldPathByValue<TFieldValues, TValue>
  >;
  select: SelectProps<TValue>;
};

export const SelectController = <
  TFieldValues extends FieldValues,
  TValue extends number | string | '' = number | '',
>({
  controller,
  select: { fieldWrapper, muiTextField, options },
}: SelectControllerProps<TFieldValues, TValue>): JSX.Element => {
  const {
    field,
    fieldState: { error },
  } = useController(controller);

  return (
    <Select
      fieldWrapper={{
        ...fieldWrapper,
        errorMessage: error?.message,
      }}
      muiTextField={{
        ...muiTextField,
        ...field,
      }}
      options={options}
    />
  );
};
CheckBoxGroup
CheckBoxGroup.tsx
import Checkbox, { CheckboxProps } from '@mui/material/Checkbox';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormGroup from '@mui/material/FormGroup';

import { FieldWrapper, FieldWrapperPassThroughProps } from './FieldWrapper';

export type CheckboxGroupProps = {
  fieldWrapper: FieldWrapperPassThroughProps;
  muiCheckbox?: CheckboxProps;
  options: {
    label: string;
    checked: boolean;
    onChange: () => void;
  }[];
};

export const CheckboxGroup: React.FC<CheckboxGroupProps> = ({
  fieldWrapper,
  muiCheckbox,
  options,
}) => {
  return (
    <FieldWrapper {...fieldWrapper}>
      <FormGroup row>
        {options.map(({ label, checked, onChange }) => (
          <FormControlLabel
            key={label}
            label={label}
            control={
              <Checkbox
                size="small"
                checked={checked}
                onChange={onChange}
                {...muiCheckbox}
              />
            }
          />
        ))}
      </FormGroup>
    </FieldWrapper>
  );
};
CheckBoxGroupController.tsx
import {
  FieldPathByValue,
  FieldValues,
  useController,
  UseControllerProps,
} from 'react-hook-form';

import { CheckboxGroup, CheckboxGroupProps } from './CheckboxGroup';
import { Options } from './types';

type CheckboxGroupControllerProps<
  TFieldValues extends FieldValues,
  TValue extends string | number,
> = {
  controller: UseControllerProps<
    TFieldValues,
    FieldPathByValue<TFieldValues, TValue[]>
  >;
  checkboxGroup: Omit<CheckboxGroupProps, 'options'> & {
    options: Options<TValue>;
  };
};

export const CheckboxGroupController = <
  TFieldValues extends FieldValues,
  TValue extends string | number = number,
>({
  controller,
  checkboxGroup,
}: CheckboxGroupControllerProps<TFieldValues, TValue>): JSX.Element => {
  const {
    field: { value, onChange, ...field },
    fieldState: { error },
  } = useController(controller);
  // PathValue<TFieldValues, FieldPathByValue<TFieldValues, TValue[]>>はTValue[]と同義
  const formValue = value as TValue[];
  const { fieldWrapper, muiCheckbox, options: checkboxOptions } = checkboxGroup;

  const handleChange = (value: TValue) => {
    const newValue = formValue.includes(value)
      ? formValue.filter((v) => v !== value)
      : [...formValue, value];
    onChange(newValue);
  };

  const options = checkboxOptions.map(({ label, value }) => ({
    label,
    value,
    checked: formValue.includes(value),
    onChange: () => handleChange(value),
  }));

  return (
    <CheckboxGroup
      fieldWrapper={{ ...fieldWrapper, errorMessage: error?.message }}
      muiCheckbox={{
        ...muiCheckbox,
        ...field,
      }}
      options={options}
    />
  );
};
MultiComboBox
MultiComboBox.tsx
import Autocomplete, { AutocompleteProps } from '@mui/material/Autocomplete';
import TextField, { TextFieldProps } from '@mui/material/TextField';

import { FieldWrapper, FieldWrapperPassThroughProps } from './FieldWrapper';
import { Option, Options } from './types';

export type MultiComboBoxProps<T extends number | string> = {
  fieldWrapper: FieldWrapperPassThroughProps;
  muiAutoComplete?: Omit<
    AutocompleteProps<Option<T>, true, false, false>,
    'renderInput' | 'options'
  >;
  muiTextField?: TextFieldProps;
  options: Options<T>;
};

export const MultiComboBox = <T extends number | string = number>({
  fieldWrapper,
  muiAutoComplete,
  muiTextField,
  options,
}: MultiComboBoxProps<T>) => {
  return (
    <FieldWrapper {...fieldWrapper}>
      <Autocomplete<Option<T>, true, false, false>
        multiple
        filterSelectedOptions
        getOptionLabel={({ label }) => label}
        options={options}
        renderInput={(params: JSX.IntrinsicAttributes & TextFieldProps) => (
          <TextField
            {...params}
            {...muiTextField}
            error={!!fieldWrapper.errorMessage}
          />
        )}
        {...muiAutoComplete}
      />
    </FieldWrapper>
  );
};
MultiComboBoxController.tsx
import {
  FieldPathByValue,
  FieldValues,
  useController,
  UseControllerProps,
} from 'react-hook-form';

import { MultiComboBox, MultiComboBoxProps } from './MultiComboBox';
import { Options } from './types';

type MultiComboBoxControllerProps<
  TFieldValues extends FieldValues,
  TValue extends number | string,
> = {
  controller: UseControllerProps<
    TFieldValues,
    FieldPathByValue<TFieldValues, Options<TValue>>
  >;
  multiComboBox: MultiComboBoxProps<TValue>;
};

export const MultiComboBoxController = <
  TFieldValues extends FieldValues,
  TValue extends number | string = number,
>({
  controller,
  multiComboBox: { fieldWrapper, muiAutoComplete, muiTextField, options },
}: MultiComboBoxControllerProps<TFieldValues, TValue>) => {
  const {
    field: { value, onChange },
    fieldState: { error },
  } = useController(controller);

  return (
    <MultiComboBox<TValue>
      fieldWrapper={{
        ...fieldWrapper,
        errorMessage: error?.message,
      }}
      muiAutoComplete={{
        ...muiAutoComplete,
        value,
        onChange: (_, value) => onChange(value),
      }}
      muiTextField={muiTextField}
      options={options}
    />
  );
};

まとめ

レイヤー分類と型定義の見直しをしたことによって、メンテナンスがしやすく、型安全に扱えるフォームコンポーネントを作成することができました。また、一部のコンポーネントを非制御コンポーネントとして扱うことで、パフォーマンスの向上も期待できます。
何より、複雑でメンテナンスしにくかった状態から、ルールが整備されて、複数人でも開発しやすい状態にできたのがよかったです!

株式会社BuySell Technologies

Discussion