徒然かえる日記

気になった技術を徒然と。

Next.js + ReactHookForm (RHF) + Zod + MUI でバリデーションの効く UI を作る

フレームワークおまかせバリバリのバリデーションが効く UI を作ります! (ただし、テキスト入力だけ…その他は気が向いたら…)
きれいな設計としては以下参考文献がおすすめ。この文書を作る際にかなり参照させていただきました!良テキスト非常に感謝です。

ラクス斉藤様著:React Hook Form v7 + MUI v5 + zod v3を使ったコンポーネント実装例

概要

  • RHF と MUI、Zod の組み合わせでの UI 作成を Next.js で実施しようとした際のポイントをまとめた
  • とりあえず手っ取り早く動かしたい際に、最低限理解しとくポイントも記載

利用 OSS のバージョン

  • Next.js : 13.4.12
  • React : 18.2.0
  • React Hook Form : 7.45.3
  • MUI : 5.14.3
  • Zod : 3.21.4

インストール

ひとまず Next.js を利用するのでインストールします。プロジェクト名や設定はお好みで!

$ npx create-next-app@latest

MUI をインストール

$ npm i @mui/material @emotion/react @emotion/styled

RHF をインストール

$ npm i react-hook-form

Zod、RHF のための Resolvers をインストール

$ npm i zod @hookform/resolvers

CustomTemplate を利用しないならこれだけで Next.js 上で MUI を利用することができます。*1

バリデーション実現!

MUI の TextField 2つに RHF + Zod でバリデーションを効かせてみます。
以下、Next.js で利用する際のポイント。

  • MUI を使うページでは先頭に”use client”;をつける。MUI は現状 Client Component でしか動作しないので必須。*2
  • TextField に必ず id つける。styled の問題らしいが深追いしてない。。。

また、MUI + RHF + Zod の連携について、少し理解すべきことが多かったので以下にポイントをまとめます。

  • zod.infer で型作って useForm にわたすと、defaultValues などで型推論効いて便利
  • defaultValues で各値を漏れなく初期化するの超重要。 詳細はJunsei Nagao様著:React Hook Form と Zod を使うときの注意点
  • Controlled なコンポーネントは RHF の Controller でラップする必要がある。TextField は Controlled、UnControlled どちらでも行けるようだが*3、RHF と組にするとなぜか Controlled 扱いでないと動かなかったため、ラップが必要 *4
  • Controller と TextField の紐づけのため inputReffield.ref を渡す
  • Controller の render メソッドで渡される fieldState にバリデーション結果が入ってくる。それを MUI の helperTexterror に渡せば良しなにしてくれる
"use client";
import { TextField } from "@mui/material";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";

const schema = z.object({
  min3: z.string().min(3, "3文字以上で入力してください!"),
  num: z.string().regex(/^[0-9]+$/, "数字以外が入力されています!"),
});

type FormType = z.infer<typeof schema>;

export default function Home() {
  const { control } = useForm<FormType>({
    mode: "onChange",
    resolver: zodResolver(schema),
    defaultValues: { min3: "abc", num: "" },
  });
  return (
    <>
      <main>
        <Controller
          name={"min3"}
          control={control}
          render={({ field, fieldState }) => (
            <TextField
              inputRef={field.ref}
              id="alpha"
              size="small"
              variant="outlined"
              {...field}
              helperText={fieldState.error?.message ?? ""}
              error={!!fieldState.error}
            ></TextField>
          )}
        ></Controller>

        <Controller
          name={"num"}
          control={control}
          render={({ field, fieldState }) => (
            <TextField
              inputRef={field.ref}
              id="text2"
              size="small"
              variant="outlined"
              {...field}
              helperText={fieldState.error?.message ?? ""}
              error={!!fieldState.error}
            ></TextField>
          )}
        ></Controller>
      </main>
    </>
  );
}

実行結果:

実行結果

コンポーネント

このままでは格好悪いので( MUI のコンポーネント使うごとにラップは面倒くさい)、最低限まとめます。ページトップに挙げた参考文献のように分けるときれいなのですが、とりあえず MUI の素のコンポーネントっぽく使えることを最低限として、簡素でシンプルにしてみました。

コンポーネント

import TextField, { TextFieldProps } from "@mui/material/TextField";
import React from "react";
import { Control, Controller, FieldValue, FieldValues } from "react-hook-form";

type TextFieldElementProps<T extends FieldValues> = TextFieldProps & {
  name: FieldValue<T>;
  control: Control<T>;
};

const TextFieldElement = <T extends FieldValues>({
  name,
  control,
  ...textFieldProps
}: TextFieldElementProps<T>) => {
  return (
    <Controller
      name={name}
      control={control}
      render={({ field, fieldState }) => (
        <TextField
          inputRef={field.ref}
          {...field}
          {...textFieldProps}
          error={!!fieldState.error?.message}
          helperText={fieldState.error?.message ?? ""}
        ></TextField>
      )}
    ></Controller>
  );
};

export default TextFieldElement;

使う側:

"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import TextFieldElement from "@/elements/TextFieldElement";

const schema = z.object({
  min3: z.string().min(3, "3文字以上で入力してください!"),
  num: z.string().regex(/^[0-9]+$/, "数字以外が入力されています!"),
});

type FormType = z.infer<typeof schema>;

export default function Home() {
  const { control } = useForm<FormType>({
    mode: "onChange",
    resolver: zodResolver(schema),
    defaultValues: { min3: "abc", num: "" },
  });
  return (
    <>
      <main>
        <TextFieldElement
          name={"min3"}
          control={control}
          id="min3-text"
          size="small"
          variant="outlined"
        ></TextFieldElement>

        <TextFieldElement
          name={"num"}
          control={control}
          id="num-text"
          size="small"
          variant="outlined"
        ></TextFieldElement>
      </main>
    </>
  );
}

割りと素の MUI コンポーネントみたいに使えるようになったのではないでしょうか!
ただ、Controller や TextField などで利用されるプロパティを使う側から渡してしまうと( onChange とか )、意図したようには動作しないので注意が必要です。この辺は RHF を普通に使う場合も同じかもしれませんが。

ではでは。