😀

Next.jsでMUI(v5)のStepperとReact hook form(v7)を使ってステップフォームを実装する

2022/12/07に公開

環境構築

  1. Next.jsの環境を用意します(今回は割愛)
  2. MUIをインストールします
npm install @mui/material @emotion/react @emotion/styled @mui/icons-material

Material UI は、Google のMaterial Designを実装するオープンソースの React コンポーネント ライブラリです。すぐに本番環境で使用できる、事前構築済みのコンポーネントの包括的なコレクションが含まれています。マテリアル UI はデザインが美しく、独自のカスタム デザイン システムをコンポーネントの上に簡単に実装できる一連のカスタマイズ オプションを備えています。
https://mui.com/material-ui/getting-started/overview/

@mui/material @mui/icons-materialはMUIのコアとなる機能を持ったnode Moduleをインストールしています。
@emotion/react @emotion/styledはMUIの見た目を変更するスタイルエンジンであるemotionをインストールしています。

*emotionを使わずにstyled-conmponentsを使うこともできるが、SSR(server side rendaring)したいときは、styled-componentsでは動作しないようなので、不自由なければ、MUIではemotionを使いましょう。

  1. React Hook Fromをインストールします
npm install react-hook-form

React Hook Form Performant, flexible and extensible forms with easy-to-use validation.

React Hook Formはreactでformのバリデーションを実装するための便利なライブラリです。
値の取得・監視・入力なども簡単にできるAPIがあり、日本語のドキュメントも豊富なので、reactでのフォーム実装はreact hook form一択と言っていいです(2022/12/5現在)

  1. yupをインストールします。
npm install @hookform/resolvers yup

yupはReact Hook Form(以下、RHF)とは別の場所で定義できるようにライブラリです。RHFにも公式でサポートしているので、組み込みやすく、RHF標準のバリデーション機能より多機能というメリットがあります。

実装

完成イメージ

実装方針は以下になります。

  1. MUIのStepperでステップフォームの骨組みを作ります
  2. ステップフォームの骨組みにRHFを組み込んでいきます
  3. バリデーション・値の保持などの要素を組み込んでいきます

MUIのStepperをステップフォームの骨組みを作ります

MUIのStepperコンポーネントを使用して、ステップフォームの骨組みを作ります。

コード全体
import {
  Box,
  Button,
  Container,
  Step,
  StepLabel,
  Stepper,
  Typography,
} from "@mui/material";
import { useState } from "react";

const steps = ["基本情報", "詳細情報", "確認"];

export default function Home() {
  const [activeStep, setActiveStep] = useState(0);
  const [skipped, setSkipped] = useState(new Set<number>());

  const isStepOptional = (step: number) => {
    return step === 1;
  };

  const isStepSkipped = (step: number) => {
    return skipped.has(step);
  };

  const handleNext = () => {
    let newSkipped = skipped;
    if (isStepSkipped(activeStep)) {
      newSkipped = new Set(newSkipped.values());
      newSkipped.delete(activeStep);
    }

    setActiveStep((prevActiveStep) => prevActiveStep + 1);
    setSkipped(newSkipped);
  };

  const handleBack = () => {
    setActiveStep((prevActiveStep) => prevActiveStep - 1);
  };

  const handleSkip = () => {
    if (!isStepOptional(activeStep)) {
      // You probably want to guard against something like this,
      // it should never occur unless someone's actively trying to break something.
      throw new Error("You can't skip a step that isn't optional.");
    }

    setActiveStep((prevActiveStep) => prevActiveStep + 1);
    setSkipped((prevSkipped) => {
      const newSkipped = new Set(prevSkipped.values());
      newSkipped.add(activeStep);
      return newSkipped;
    });
  };

  const handleReset = () => {
    setActiveStep(0);
  };
  return (
    <Container>
      <Box sx={{ width: "100%" }}>
        <Stepper activeStep={activeStep}>
          {steps.map((label, index) => {
            const stepProps: { completed?: boolean } = {};
            const labelProps: {
              optional?: React.ReactNode;
            } = {};
            if (isStepOptional(index)) {
              labelProps.optional = (
                <Typography variant="caption">オプション</Typography>
              );
            }
            if (isStepSkipped(index)) {
              stepProps.completed = false;
            }
            return (
              <Step key={label} {...stepProps}>
                <StepLabel {...labelProps}>{label}</StepLabel>
              </Step>
            );
          })}
        </Stepper>
        {activeStep === steps.length ? (
          <div>
            <Typography sx={{ mt: 2, mb: 1 }}>
              All steps completed - you&apos;re finished
            </Typography>
            <Box sx={{ display: "flex", flexDirection: "row", pt: 2 }}>
              <Box sx={{ flex: "1 1 auto" }} />
              <Button onClick={handleReset}>Reset</Button>
            </Box>
          </div>
        ) : (
          <div>
            <Typography sx={{ mt: 2, mb: 1 }}>Step {activeStep + 1}</Typography>
            <Box sx={{ display: "flex", flexDirection: "row", pt: 2 }}>
              <Button
                color="inherit"
                disabled={activeStep === 0}
                onClick={handleBack}
                sx={{ mr: 1 }}
              >
                Back
              </Button>
              <Box sx={{ flex: "1 1 auto" }} />
              {isStepOptional(activeStep) && (
                <Button color="inherit" onClick={handleSkip} sx={{ mr: 1 }}>
                  Skip
                </Button>
              )}
              <Button onClick={handleNext}>
                {activeStep === steps.length - 1 ? "Finish" : "Next"}
              </Button>
            </Box>
          </div>
        )}
      </Box>
    </Container>
  );
}

解説

  • Stepper要素について
<Stepper activeStep={activeStep}>

StepperタグのactiveStep属性にステップの段階を監視しているの状態変数を渡します。(今回で言うとactiveStepです。)
現在のStepperがどの状態にあるかをactiveStep状態変数を用いて管理しています。

Stepperタグでは、コンテンツの上に表示されている「現在どのステップの段階にいる・どこまでのステップが終了しているか」、という表示を作成しています。↓

  • Step要素について
const steps = ["基本情報", "詳細情報", "確認"];
~~~
          {steps.map((label) => (
            <Step key={label}>
              <StepLabel>{label}</StepLabel>
            </Step>
          ))}

Stepタグの要素を配列の要素の数だけ生成している。StepLabelタグでステップの番号を表し、StepLabelタグのコンテンツに書いた文字列がステップの項目名として表示される
const steps = ["基本情報", "詳細情報", "確認", "hoge1", "hog2"];の時

  • コンテンツの切り替えについて
  const [activeStep, setActiveStep] = useState(0);
~~~
        {activeStep === steps.length ? (
~~~
        ) : (
~~~
        )}

activeSteps(ステップの状態変数)に応じて表示させるコンテンツを切り替えています
ボタン要素を押すと状態変数を切り替える関数が実行されます。
上記のような流れでステップの状態に応じてコンテンツの切り替えを行なっています。

ステップフォームの骨組みにRHFを組み込んでいきます

ステップ毎に切り替えるコンテンツの内容をコンポーネントに分けて、index.tsxで表示切り替えを行なっていきます。切り分けたコンポーネントにRHFを追加してフォームとして機能を追加していきます。

index.tsx全文
index.tsx
import {
  Box,
  Button,
  Container,
  Step,
  StepLabel,
  Stepper,
  Typography,
} from "@mui/material";
import { useState } from "react";
import Basic from "../components/forms/Basic";
import Confirm from "../components/forms/Confirm";
import Optional from "../components/forms/Optional";

const steps = ["基本情報", "詳細情報", "確認"];

export default function Home() {
  const [activeStep, setActiveStep] = useState(0);
  const [formValue, setFormValue] = useState({});

  const handleNext = () => {
    setActiveStep((prevActiveStep) => prevActiveStep + 1);
  };

  const handleBack = () => {
    setActiveStep((prevActiveStep) => prevActiveStep - 1);
  };

  const handleReset = () => {
    setActiveStep(0);
  };

  const changeFormConponent = (activeStep: number) => {
    switch (activeStep) {
      case 0:
        return (
          <Basic
            handleNext={handleNext}
            formValue={formValue}
            setFormValue={setFormValue}
          />
        );
      case 1:
        return (
          <Optional
            handleBack={handleBack}
            handleNext={handleNext}
            formValue={formValue}
            setFormValue={setFormValue}
          />
        );
      case 2:
        return (
          <Confirm
            handleBack={handleBack}
            handleNext={handleNext}
            formValue={formValue}
          />
        );
    }
  };
  return (
    <Container>
      <Box sx={{ width: "100%" }}>
        <Stepper activeStep={activeStep} alternativeLabel>
          {steps.map((label) => (
            <Step key={label}>
              <StepLabel>{label}</StepLabel>
            </Step>
          ))}
        </Stepper>
        {activeStep === steps.length ? (
          <div>
            <Typography sx={{ mt: 2, mb: 1 }}>
              All steps completed - you&apos;re finished
            </Typography>
            <Box sx={{ display: "flex", flexDirection: "row", pt: 2 }}>
              <Button onClick={handleReset}>Reset</Button>
            </Box>
          </div>
        ) : (
          <div>
            <Typography sx={{ mt: 2, mb: 1 }}>Step {activeStep + 1}</Typography>
            {changeFormConponent(activeStep)}
          </div>
        )}
      </Box>
    </Container>
  );
}
Basic.tsx全文
Basic.tsx
import { Box, Button, TextField } from "@mui/material";
import { Controller, useForm } from "react-hook-form";

export interface MyformBasic {
  nameBox: string;
}

function Basic(props: any) {
  const { control, handleSubmit, setValue } = useForm<MyformBasic>({
    defaultValues: {
      nameBox: "",
    },
  });

  const onSubmit = (data: MyformBasic) => {
    console.log(data);
    props.handleNext();
    props.setFormValue({ ...props.formValue, BasicForm: data });
  };

  return (
    <div>
      <form onSubmit={handleSubmit(onSubmit)}>
        <Controller
          name="nameBox"
          control={control}
          render={({ field }) => (
            <TextField
              {...field}
              type="text"
              label="名前"
              fullWidth
            ></TextField>
          )}
        />
        <Box sx={{ display: "flex", flexDirection: "row", pt: 2 }}>
          <Button variant="outlined" disabled sx={{ mr: 1 }}>
            戻る
          </Button>
          <Button onClick={handleSubmit(onSubmit)} variant="outlined">
            次へ
          </Button>
        </Box>
      </form>
    </div>
  );
}

export default Basic;

解説

切り分けたコンポーネントにRHFを組み込んでいきます。
今回MUIとRHFを使用するので、RHFのControllerコンポーネントを使用します

RHFでのフォーム実装について

react-hook-formでのFormの作り方には2つ方法があります。

  • [useForm]のregisterを使って、<input {...register('hoge')} />とします
  • [useForm]のcontrolと[RHFのController]を使って、<Controller ../>とします

そもそも、Reactフォームを実装する方針としては2種類あります。

  • Uncontrolled Components
    • 素の<input/>など
  • Controlled Components
    • MUIなど、外部のUI libraryが提供するfieldなど

Reactの公式ドキュメントを確認してみると、Reactでフォーム実装をする際はControlled Componentが推奨されています。

ほとんどの場合では、フォームの実装には制御されたコンポーネント (controlled component) を使用することをお勧めしています。制御されたコンポーネントでは、フォームのデータは React コンポーネントが扱います。非制御コンポーネント (uncontrolled component) はその代替となるものであり、フォームデータを DOM 自身が扱います。
https://ja.reactjs.org/docs/uncontrolled-components.html

React Hook Form(以下RHF)では、UnControlled Componentが推奨されているが、MUIなどのControlled Componentを利用するときは、Controllerコンポーネントを利用するとRHFとMUIの統合が上手にいきます。
今回の実装も、RHFのControllerコンポーネントを利用します。

React Hook Form は非制御コンポーネントとネイティブ入力を受け入れますが、React-Select、AntD、MUI などの外部制御コンポーネントの使用を避けるのは困難です。このラッパー コンポーネント(Controller)を使用すると、それらを簡単に操作できます。
https://react-hook-form.com/api/usecontroller/controller

FYI

非制御コンポーネントについて
https://ja.reactjs.org/docs/uncontrolled-components.html

https://zenn.dev/longbridge/articles/640710005e11b1

コントローラーコンポーネント
https://react-hook-form.com/api/usecontroller/controller

registerかControllerかどっちを使うか
https://scrapbox.io/mrsekut-p/react-hook-formでregisterとControllerのどちらを使うか

  • useFormについて
~~~
  const { control, handleSubmit } = useForm<MyformBasic>({
    defaultValues: {
      nameBox: "",
    },
  });
~~~

RHFのuseForm APIを使用して、フォームの管理を行なっています。引数にフォームのデフォルトの値などを指定できます。useFormを実行するとフォームの情報に関するオブジェクトが返却されて、そのブロパティを使ってフォームの情報を取得したりします。

プロパティを個別に見ていきます。

・control  RHFのControllerコンポーネントの属性 controlとuseFormのcontrol propsを使用してコンポーネントにフォームに登録する
・handleSubmit: ((data: Object, e?: Event) => void, (errors: Object, e?: Event) => void) => Function
この関数はフォームのバリデーションが成功した時にフォームの入力データを返却します。

  • Controllerコンポーネントについて
~~~
        <Controller
          name="nameBox"
          control={control}
          render={({ field }) => (
            <TextField
              {...field}
              type="text"
              label="名前"
              fullWidth
            ></TextField>
          )}
        />
~~~

Controller コンポーネントを使用してControlled Component(MUI)の要素をRFHに登録します。
Controller コンポーネントの属性を見ていきます。
・name 一意のフォームの名前 フォームの要素を取得するときにnameで指定した文字列を呼び出します
・control useFormで返却したpropsのcontrolオブジェクトを入れてこのインプット要素をuseFormに登録します
・render reactエレメントを返却する関数です。返却するreactエレメントにRHFのイベントや値を提供して返却します。上記では、fieldオブジェクトをTextField要素に渡しています。

  • useStateでフォームの値を保持したい
    親コンポーネントで状態を管理して、複数の子コンポーネントにpropsとして状態を渡すuseStateを使った実装を行います。今回で言うと、index.tsxからBasic,Optional,Confirmコンポーネントにindex.tsxで持っている状態変数を渡しています。
Basic.tsx
function Basic(props: any) {
~~~
  const onSubmit = (data: MyformBasic) => {
    console.log(data);
    props.handleNext();
    props.setFormValue({ ...props.formValue, BasicForm: data });
  };
~~~
}

propsとして、子コンポーネントで状態変数を受け取っています。
setFormState関数でformStateのオブジェクトの中身を更新しています。

コンポーネント間でのStateの保持の仕方

基本的にuseStateが使えるところではuseStateを使用する
認証系やアプリケーション全体で状態を保ちたいものなどにuseContextを使用する。

  • useState
    props を介して親コンポーネントから子コンポーネントに情報を渡します。

  • useContext
    親コンポーネントは、その下のツリー内の任意のコンポーネントが、その下のツリーの深さに関係なく、明示的に props を介して情報を渡すことなく、一部の情報を利用できるようになります。

useContext
https://beta.reactjs.org/apis/react/useContext

useContextとRedux
https://zenn.dev/luvmini511/articles/61e8e54853bc13

バリデーション・値の保持などの要素を組み込んでいきます

RHFの機能を使ってFormとしての機能は最低限できましたので、フォームを進めて戻ってきた時に前の値をフォームに入力するという機能とバリデーションの機能の実装を追加して完成とします。

  • フォームを進めて戻ってきた時に前の値をフォームに入力したい
  const { control, handleSubmit, setValue } = useForm<MyformBasic>({
    defaultValues: {
      nameBox: "",
    },
  });

  useEffect(() => {
    if (props.formValue.BasicForm) {
      setValue("nameBox", props.formValue.BasicForm.nameBox, {
        shouldDirty: true,
      });
    }
  }, []);

useFormのプロパティでsetValue関数を呼び出します。
useEffectを使用して、画面の再レンダリング後に一回だけsetValue関数を実行して、既存のフォームの値を代入しています。

  • yupを使ってバリデーションを実装したい

Yupを使用して、RHFのバリデーションを行っています。

import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";

const schema = yup
  .object({
    nameBox: yup.string().required("必須項目です"),
  })
  .required();

function Basic(props: any) {
~~~
  const {
    control,
    handleSubmit,
    setValue,
    formState: { errors },
  } = useForm<MyformBasic>({
    defaultValues: {
      nameBox: "",
    },
    resolver: yupResolver(schema),
    mode: "all",
  });
~~~
}

yupをインポートしてきて、schemaを定義します。schemaはフォームのバリデーションルールを持ったオブジェクトです。
useFormのresolverプロパティにschema渡すことで、RHFとyupを繋ぎます。
modeプロパティをallにすることによって、バリデーションがかかるタイミングを「フォーカスを当てて離した瞬間」と「フォームの値を入力している時」に設定します。

~~~
        <Controller
          name="nameBox"
          control={control}
          render={({ field }) => (
            <TextField
              {...field}
              type="text"
              label="名前"
              error={errors.nameBox ? true : false}
              helperText={errors.nameBox?.message}
              fullWidth
            ></TextField>
          )}
        />
~~~

上でuseFormから返却されたformStateオブジェクトのerrorsプロパティを呼び出して使用します。

MUIの仕様でTextFieldタグの属性errorはtrueの時TextFieldをエラー表示になります。errors.nameBoxはバリデーションエラーの時にRFHがオブジェクトを生成するため、errors.nameBoxが生成されたタイミングでエラー表示を行うという実装を行いました。

最終的なコード

index.tsx全文
index.tsx
import {
  Box,
  Button,
  Container,
  Step,
  StepLabel,
  Stepper,
  Typography,
} from "@mui/material";
import { useState } from "react";
import Basic from "../components/forms/Basic";
import Confirm from "../components/forms/Confirm";
import Optional from "../components/forms/Optional";

const steps = ["基本情報", "詳細情報", "確認"];

export default function Home() {
  const [activeStep, setActiveStep] = useState(0);
  const [formValue, setFormValue] = useState({});

  const handleNext = () => {
    setActiveStep((prevActiveStep) => prevActiveStep + 1);
  };

  const handleBack = () => {
    setActiveStep((prevActiveStep) => prevActiveStep - 1);
  };

  const handleReset = () => {
    setActiveStep(0);
  };

  const changeFormConponent = (activeStep: number) => {
    switch (activeStep) {
      case 0:
        return (
          <Basic
            handleNext={handleNext}
            formValue={formValue}
            setFormValue={setFormValue}
          />
        );
      case 1:
        return (
          <Optional
            handleBack={handleBack}
            handleNext={handleNext}
            formValue={formValue}
            setFormValue={setFormValue}
          />
        );
      case 2:
        return (
          <Confirm
            handleBack={handleBack}
            handleNext={handleNext}
            formValue={formValue}
          />
        );
    }
  };
  return (
    <Container>
      <Box sx={{ width: "100%" }}>
        <Stepper activeStep={activeStep} alternativeLabel>
          {steps.map((label) => (
            <Step key={label}>
              <StepLabel>{label}</StepLabel>
            </Step>
          ))}
        </Stepper>
        {activeStep === steps.length ? (
          <div>
            <Typography sx={{ mt: 2, mb: 1 }}>
              All steps completed - you&apos;re finished
            </Typography>
            <Box sx={{ display: "flex", flexDirection: "row", pt: 2 }}>
              <Button onClick={handleReset}>Reset</Button>
            </Box>
          </div>
        ) : (
          <div>
            <Typography sx={{ mt: 2, mb: 1 }}>Step {activeStep + 1}</Typography>
            {changeFormConponent(activeStep)}
          </div>
        )}
      </Box>
    </Container>
  );
}

Basic.tsx全文
Basic.tsx
import { Box, Button, TextField } from "@mui/material";
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";

export interface MyformBasic {
  nameBox: string;
}

const schema = yup
  .object({
    nameBox: yup.string().required("必須項目です"),
  })
  .required();

function Basic(props: any) {
  const {
    control,
    handleSubmit,
    setValue,
    formState: { errors },
  } = useForm<MyformBasic>({
    defaultValues: {
      nameBox: "",
    },
    resolver: yupResolver(schema),
    mode: "all",
  });

  const onSubmit = (data: MyformBasic) => {
    console.log(data);
    props.handleNext();
    props.setFormValue({ ...props.formValue, BasicForm: data });
  };

  useEffect(() => {
    if (props.formValue.BasicForm) {
      setValue("nameBox", props.formValue.BasicForm.nameBox, {
        shouldDirty: true,
      });
    }
  }, []);
  console.log(errors.nameBox);

  return (
    <div>
      <form onSubmit={handleSubmit(onSubmit)}>
        <Controller
          name="nameBox"
          control={control}
          render={({ field }) => (
            <TextField
              {...field}
              type="text"
              label="名前"
              error={errors.nameBox ? true : false}
              helperText={errors.nameBox?.message}
              fullWidth
            ></TextField>
          )}
        />
        <Box sx={{ display: "flex", flexDirection: "row", pt: 2 }}>
          <Button variant="outlined" disabled sx={{ mr: 1 }}>
            戻る
          </Button>
          <Button onClick={handleSubmit(onSubmit)} variant="outlined">
            次へ
          </Button>
        </Box>
      </form>
    </div>
  );
}

export default Basic;

Optional.tsx全文
Optional.tsx
import { Box, Button, TextField } from "@mui/material";
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";

export interface MyformOptional {
  optionalBox: string;
}

const schema = yup
  .object({
    optionalBox: yup.string().required("必須項目です"),
  })
  .required();

function Optional(props: any) {
  const { control, handleSubmit, setValue, formState:{errors}} =
    useForm<MyformOptional>({
      defaultValues: {
        optionalBox: "",
      },
      resolver: yupResolver(schema),
      mode: "all",
    });

  const onSubmit = (data: MyformOptional) => {
    console.log(data);
    props.handleNext();
    props.setFormValue({ ...props.formValue, OptionalForm: data });
  };

  useEffect(() => {
    if (props.formValue.OptionalForm) {
      setValue("optionalBox", props.formValue.OptionalForm.optionalBox, {
        shouldDirty: true,
      });
    }
  }, []);

  return (
    <div>
      <form onSubmit={handleSubmit(onSubmit)}>
        <Controller
          name="optionalBox"
          control={control}
          render={({ field }) => (
            <TextField
              {...field}
              type="text"
              label="備考"
              error={errors.optionalBox ? true : false}
              helperText={errors.optionalBox?.message}
              fullWidth
            ></TextField>
          )}
        />
        <Box sx={{ display: "flex", flexDirection: "row", pt: 2 }}>
          <Button variant="outlined" onClick={props.handleBack} sx={{ mr: 1 }}>
            戻る
          </Button>
          <Button
            onClick={handleSubmit(onSubmit)}
            variant="outlined"
            sx={{ mr: 1 }}
          >
            確認へ
          </Button>
        </Box>
      </form>
    </div>
  );
}

export default Optional;

Confirm.tsx全文
Confirm.tsx
import { Box, Button, Typography } from "@mui/material";

function Confirm(props: any) {
  const handleSubmit = () => {
    console.log(props.formState);
    props.handleNext();
  };
  return (
    <div>
      <Typography>{props.formValue.BasicForm.nameBox}</Typography>
      <Typography>{props.formValue.OptionalForm.optionalBox}</Typography>
      <Box sx={{ display: "flex", flexDirection: "row", pt: 2 }}>
        <Button variant="outlined" onClick={props.handleBack} sx={{ mr: 1 }}>
          戻る
        </Button>
        <Button onClick={handleSubmit} variant="outlined">
          提出
        </Button>
      </Box>
    </div>
  );
}

export default Confirm;

FYI

MUI(v5)
https://mui.com/material-ui/getting-started/overview/

RHF公式ドキュメント(今回はVersion.7を使用します。)
https://react-hook-form.com/

スクラップで学習過程を表しています。
https://zenn.dev/kisukeyas/scraps/0775a947e1fab5

Discussion