RAKUS Developers Blog | ラクス エンジニアブログ

株式会社ラクスのITエンジニアによる技術ブログです。

React Hook Form v7 + MUI v5 + zod v3を使ったコンポーネント実装例

本記事を執筆するにあたって、

という記事を参考にさせていただきました。いつも非常にわかりやすい記事をありがとうございます。

はじめに

こんにちは、ラクスフロントエンド開発課の斉藤です。 React Hook Form v7 + MUI v5 + zod v3を使ったよく使うコンポーネントの実装例を調査しており、こちらの記事を参考に実装を進めてみました。しかしRadioGroupDatePickeratom化しようとすると何点かハマりポイントがあったので、どなたかの参考になればと思い本記事を執筆するに至りました。

実装例を紹介する前に各コンポーネントを実装するにあたって考慮したことをまとめておきます。React Hook Form(以下RHF)を用いたうえで扱いやすいコンポーネントはどういったものかを考えたとき、以下の条件を満たすと良いのではないかと考えました。

  1. コンポーネントとバリデーションロジックが切り離されている
  2. マナリンクさんの記事のようにコンポーネントがview層とロジック層に分かれている
  3. コンポーネントを使用する側はnameとRHFのcontrolプロパティを渡すだけで値を管理できる
  4. CheckboxGroupはチェックされたCheckboxの値をstring型の配列で返すようにする
  5. RadioGroup、SelectFormは選択された値をstring型で返すようにする
  6. DatePickerは値をDate型で返す

これらを満たすように各コンポーネントを実装してみたので、紹介していきます。

今回紹介する環境はcreate viteで作っています。

yarn create vite
yarn add react-hook-form @hookform/resolvers @mui/material @emotion/react @emotion/styled zod

TextField

TextFieldに関しては参考記事とほぼ同じ実装です。

import {
  FormHelperText,
  TextField as MuiTextField,
  TextFieldProps as MuiTextFieldProps,
} from "@mui/material";

export type TextFieldProps = MuiTextFieldProps & {
  inputRef?: MuiTextFieldProps["ref"];
  errorMessage?: string;
};

export const TextField: React.FC<TextFieldProps> = ({
  inputRef,
  errorMessage,
  ...rest
}) => {
  return (
    <>
      <MuiTextField ref={inputRef} error={!!errorMessage} {...rest} />
      {!!errorMessage && <FormHelperText error>{errorMessage}</FormHelperText>}
    </>
  );
};
import {
  DeepMap,
  FieldError,
  FieldValues,
  useController,
  UseControllerProps,
} from "react-hook-form";
import { TextField, TextFieldProps } from "./TextField";

export type RhfTextFieldProps<T extends FieldValues> = TextFieldProps &
  UseControllerProps<T>;

export const RhfTextField = <T extends FieldValues>(
  props: RhfTextFieldProps<T>
) => {
  const { name, control } = props;
  const {
    field: { ref, ...rest },
    fieldState: { error },
  } = useController<T>({ name, control });

  return (
    <TextField
      inputRef={ref}
      {...rest}
      {...props}
      errorMessage={(error && error.message) || props.errorMessage}
    />
  );
};

RadioGroup

RadioGroupにはRadioPropsListとしてvalueとlabelをプロパティに持つ配列を渡せるようにします。labelが画面に表示されるラジオボタン横の文言で、valueが実際にRHFが受け取る値になります。例えばラジオボタンで「りんご」を選択したとき、バックエンド側には「apple」と英名で情報を送信したい場合が良くあるのでこのように分けています。

import {
  FormControl,
  FormControlLabel,
  FormHelperText,
  Radio,
  RadioGroup as MuiRadioGroup,
} from "@mui/material";
import type { RadioGroupProps as MuiRadioGroupProps } from "@mui/material";

type RadioProps = {
  value: string;
  label: string;
};

export type RadioGroupProps = MuiRadioGroupProps & {
  inputRef?: MuiRadioGroupProps["ref"];
  errorMessage?: string;
  radioPropsList: RadioProps[];
};

export const RadioGroup: React.FC<RadioGroupProps> = ({
  inputRef,
  radioPropsList,
  errorMessage,
  ...rest
}) => {
  return (
    <div>
      <FormControl error={!!errorMessage}>
        <MuiRadioGroup ref={inputRef} {...rest}>
          {radioPropsList.map((el) => (
            <FormControlLabel
              key={el.value}
              value={el.value}
              label={el.label}
              control={<Radio />}
            />
          ))}
        </MuiRadioGroup>
      </FormControl>
      {!!errorMessage && <FormHelperText error>{errorMessage}</FormHelperText>}
    </div>
  );
};
import { useController } from "react-hook-form";
import type {
  FieldValues,
  UseControllerProps,
  DeepMap,
  FieldError,
} from "react-hook-form";
import { RadioGroup, RadioGroupProps } from "./RadioGroup";

export type RhfRadioGroupProps<T extends FieldValues> = RadioGroupProps &
  UseControllerProps<T>;

export const RhfRadioGroup = <T extends FieldValues>(
  props: RhfRadioGroupProps<T>
): JSX.Element => {
  const { name, control, ...rest } = props;
  const {
    field: { ref, ...restControllerProps },
  } = useController<T>({ name, control });

  return <RadioGroup inputRef={ref} {...restControllerProps} {...rest} />;
};

SelectForm

SelectFormは機能としてはRadioGroupに近いのでほぼ同じ実装になっています。ただselectedValueをプロパティとして渡せるようにしないと、現在選択されている値をフォーム上に表示することができないので渡しています。RHFを使うと「現在選択されている値」の情報はuseControllerから持ってこれるので、そのままview層に渡します。

またMuiのSelectコンポーネントをスタイリングせずにそのまま使うと、以下のgifのようにwidthが極端に小さいコンポートとなってしまいました。実際に使うときにはお好みのスタイリング手法でwidthを設定したほうが良いでしょう。ただMuiのコンポーネントにスタイルを当てたい場合は公式に用意されているstyled()を使うのがオススメです。

import {
  FormControl,
  FormHelperText,
  InputLabel,
  MenuItem,
  Select,
} from "@mui/material";
import type { SelectProps as MuiSelectProps } from "@mui/material";

type SelectProps = {
  label: string;
  value: string;
};

export type SelectFormProps = MuiSelectProps & {
  inputRef?: MuiSelectProps["ref"];
  errorMessage?: string;
  selectPropsList: SelectProps[];
  selectedValue: string;
};

export const SelectForm: React.FC<SelectFormProps> = ({
  inputRef,
  errorMessage,
  selectPropsList,
  selectedValue,
  label,
  ...rest
}) => {
  return (
    <div>
      <FormControl>
        <InputLabel>{label}</InputLabel>
        <Select ref={inputRef} value={selectedValue} label={label} {...rest}>
          {selectPropsList.map((props) => (
            <MenuItem key={props.value} value={props.value}>
              {props.label}
            </MenuItem>
          ))}
        </Select>
      </FormControl>
      {!!errorMessage && <FormHelperText error>{errorMessage}</FormHelperText>}
    </div>
  );
};
import { useController } from "react-hook-form";
import type {
  FieldValues,
  UseControllerProps,
  DeepMap,
  FieldError,
} from "react-hook-form";
import { SelectForm, SelectFormProps } from "./SelectForm";

export type RhfSelectFormProps<T extends FieldValues> = Omit<
  SelectFormProps,
  "selectedValue"
> &
  UseControllerProps<T>;

export const RhfSelectForm = <T extends FieldValues>(
  props: RhfSelectFormProps<T>
): JSX.Element => {
  const { name, control } = props;
  const {
    field: { ref, onChange, value: selectedValue, ...rest },
    fieldState: { error },
  } = useController<T>({ name, control });

  return (
    <SelectForm
      inputRef={ref}
      onChange={(e) => onChange(e)}
      {...rest}
      {...props}
      selectedValue={selectedValue}
      errorMessage={(error && error.message) || props.errorMessage}
    />
  );
};

CheckboxGroup

CheckboxGroupにはチェックした値が配列として返ってくるようにしています。例えば「りんご」「ばなな」をチェックすると["apple", "banana"]が返ってきます。

ロジックとしてはRhfCheckboxGrouphandleChangeとRHFのonChange関数を使って実現しています。handleChangeでチェックした値の配列を作り、onChange関数の引数に渡すことでRHFに作成した配列の情報を渡すことができます。

import React from "react";
import {
  Checkbox,
  FormControlLabel,
  FormGroup,
  FormHelperText,
} from "@mui/material";
import type { FormGroupProps } from "@mui/material";

type CheckboxProps = {
  value: string;
  label: string;
};

export type CheckboxGroupProps = FormGroupProps & {
  inputRef?: FormGroupProps["ref"];
  errorMessage?: string;
  checkBoxPropsList: CheckboxProps[];
  checkedValues: string[];
};

export const CheckboxGroup: React.FC<CheckboxGroupProps> = ({
  inputRef,
  checkBoxPropsList,
  checkedValues,
  errorMessage,
  ...rest
}) => {
  return (
    <div>
      <FormGroup ref={inputRef} {...rest}>
        {checkBoxPropsList.map((props) => (
          <FormControlLabel
            key={props.value}
            control={
              <Checkbox
                value={props.value}
                checked={checkedValues.includes(props.value)}
              />
            }
            label={props.label}
          />
        ))}
      </FormGroup>
      {!!errorMessage && <FormHelperText error>{errorMessage}</FormHelperText>}
    </div>
  );
};
import React from "react";
import { DeepMap, FieldError, useController } from "react-hook-form";
import type { FieldValues, UseControllerProps } from "react-hook-form";
import { CheckboxGroup, CheckboxGroupProps } from "./CheckboxGroup";

export type RhfCheckboxGroupProps<T extends FieldValues> = Omit<
  CheckboxGroupProps,
  "checkedValues"
> &
  UseControllerProps<T>;

export const RhfCheckboxGroup = <T extends FieldValues>(
  props: RhfCheckboxGroupProps<T>
): JSX.Element => {
  const { name, control } = props;
  const {
    field: { ref, onChange, value: checkedValues, ...rest },
    fieldState: { error },
  } = useController<T>({ name, control });

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    let newCheckedValueList: string[] = [];
    if (e.target.checked) {
      // チェックボックスがチェックされた時、チェックされた値を重複値の無い配列に追加
      newCheckedValueList = [...new Set([...checkedValues, e.target.value])];
    } else {
      // チェックボックスが外された時は、チェックが外された値を配列から削除
      newCheckedValueList = [...checkedValues].filter(
        (value) => value !== e.target.value
      );
    }
    return newCheckedValueList;
  };

  return (
    <CheckboxGroup
      inputRef={ref}
      onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
        onChange(handleChange(e))
      }
      {...rest}
      checkBoxPropsList={props.checkBoxPropsList}
      checkedValues={[...checkedValues]}
      errorMessage={(error && error.message) || props.errorMessage}
    />
  );
};

DatePicker

DatePickerはMuiの構成上view層とロジック層を分けることができませんでした。renderInputでRhfTextFieldを直接渡すことでRHFに対応させるようにしています。またMuiのデフォルトだとDatePickerのplaceholderにy/mm/ddと表示されてしまうので、inputPropsからplaceholderを設定するようにしています。またdefaultValue={undefined}を指定しないと型エラーが出てしまうので設定しています。

MuiのDatePickerはカレンダーアイコンから日付を選択することもできるし、TextFieldに直接日付を入力することもできます。直接日付を入力した際は文字列をDate型としてparseしたいのでdate-fnsのparse関数を用いています。また数値以外の文字列を入力できるとinvalid dateとなってしまうので、onChange内で正規表現を用いて入力できないようにしています。

またMuiのDatePickerはバックスペースなどで入力した日付を全て消すと値としてはnullが入るのでzod側ではnullを許容するようにしています。

import { DatePicker } from "@mui/x-date-pickers";
import { parse } from "date-fns";
import { useController } from "react-hook-form";
import type { FieldValues, UseControllerProps } from "react-hook-form";
import { RhfTextField } from "./RhfTextField";

/** 日付フォーマットyyyy/MM/ddを文字列とみなした時の長さは10 */
const DATE_FORMAT_LENGTH = 10;

export type RhfDatePickerProps<T extends FieldValues> = UseControllerProps<T>;

export const RhfDatePicker = <T extends FieldValues>(
  props: RhfDatePickerProps<T>
) => {
  const { name, control } = props;
  const {
    field: { onChange, value },
  } = useController<T>({ name, control });

  const onSelectDate = (e: Date | null) => {
    onChange(e);
  };

  const onChangeText = (value: string) => {
    // MUIのDatePickerはデフォルトで10文字より多く入力できてしまうため、10文字を超えた分は省略する
    // ex) yyyy/MM/dd{任意の文字}のように入力できてしまう
    if (value.length > DATE_FORMAT_LENGTH) {
      onChange(
        parse(value.slice(0, DATE_FORMAT_LENGTH), "yyyy/MM/dd", new Date())
      );
      return;
    }
    onChange(parse(value, "yyyy/MM/dd", new Date()));
  };

  return (
    <DatePicker
      value={value || null}
      onChange={(e: Date | null) => onSelectDate(e)}
      renderInput={(params) => (
        <RhfTextField
          {...params}
          inputProps={{
            ...params.inputProps,
            placeholder: "yyyy/MM/dd",
          }}
          error={!!errors[name]}
          onChange={(e) => {
            // 数値以外を弾く
            if (!/^\d*$/.test(e.target.value)) return;
            onChangeText(e.target.value);
          }}
          defaultValue={undefined}
          name={name}
          control={control}
        />
      )}
    />
  );
};

コンポーネント使用側実装例

参考までにこれまでに紹介したコンポーネントの使用側実装例を掲載しておきます。

import "./App.css";
import { styled, Button } from "@mui/material";
import { z } from "zod";
import { useForm, SubmitHandler } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { RhfTextField } from "./components/RhfTextField";
import { RhfRadioGroup } from "./components/RhfRadioGroup";
import { RhfSelectForm } from "./components/RhfSelectForm";
import { RhfCheckboxGroup } from "./components/RhfCheckboxGroup";
import { RhfDatePicker } from "./components/RhfDatePicker";

const Form = styled("form")({
  display: "flex",
  flexDirection: "column",
  gap: "16px",
  alignItems: "center",
  width: "100%",
  padding: "16px",
});

const Flex = styled("div")({
  display: "flex",
  gap: "16px",
});

const schema = z.object({
  text: z.string().min(1, { message: "Required" }),
  radio: z.string().min(1, { message: "Required" }),
  select: z.string().min(1, { message: "Required" }),
  checkbox: z.string().array().min(1, { message: "Required" }),
  date: z
    .date()
    .nullable()
    .refine((date) => date !== null, "Required"),
});

type Inputs = z.infer<typeof schema>;

const defaultValues: Inputs = {
  text: "",
  radio: "",
  select: "",
  checkbox: [],
  date: null,
};

const props = [
  {
    label: "りんご",
    value: "apple",
  },
  {
    label: "みかん",
    value: "orange",
  },
  {
    label: "ばなな",
    value: "banana",
  },
];

function App() {
  const { control, handleSubmit, reset } = useForm<Inputs>({
    defaultValues: defaultValues,
    resolver: zodResolver(schema),
  });

  const onSubmit: SubmitHandler<Inputs> = (data) => console.log(data);
  return (
    <Form onSubmit={handleSubmit(onSubmit)}>
      <RhfTextField label="Text" name="text" control={control} />
      <RhfRadioGroup name="radio" control={control} radioPropsList={props} />
      <RhfSelectForm
        label="Select"
        name="select"
        control={control}
        selectPropsList={props}
      />
      <RhfCheckboxGroup
        name="checkbox"
        control={control}
        checkBoxPropsList={props}
      />
      <RhfDatePicker name="date" control={control} />
      <Flex>
        <Button type="submit">送信</Button>
        <Button onClick={() => reset()}>リセット</Button>
      </Flex>
    </Form>
  );
}

export default App;

おわりに

よく使うコンポーネントをRHF化して子コンポーネントとして作成することができました。これらのコンポーネントを組み合わせ、zodと連携することで様々なバリデーション機能を持ったフォームを作成することができると思います。DatePickerに関しては若干ゴリ押しの実装になってしまった感が否めませんが自分の実力ではこれが限界でした...。

未だにRHFの底が見えていないのでどんどん使い倒してマスターできるようになっていきたいです。


エンジニア中途採用サイト
ラクスでは、エンジニア・デザイナーの中途採用を積極的に行っております!
ご興味ありましたら是非ご確認をお願いします。
20210916153018
https://career-recruit.rakus.co.jp/career_engineer/

カジュアル面談お申込みフォーム
どの職種に応募すれば良いかわからないという方は、カジュアル面談も随時行っております。
以下フォームよりお申込みください。
rakus.hubspotpagebuilder.com

ラクスDevelopers登録フォーム
20220701175429
https://career-recruit.rakus.co.jp/career_engineer/form_rakusdev/

イベント情報
会社の雰囲気を知りたい方は、毎週開催しているイベントにご参加ください!

◆TECH PLAY
techplay.jp

◆connpass
rakus.connpass.com

Copyright © RAKUS Co., Ltd. All rights reserved.