Closed19

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

k_yask_yas

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/

上記コマンドでnode moduleをインストールします。
@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を使いましょう。

FYI

エモーションはJavaScriptでCSSを書くときに使用するライブラリです。
MUI systemでCSSを調整する時に使うsx propsの大元で使われている技術です。
https://emotion.sh/docs/introduction

MUIのCSSの書き方 sx={{}}を要素のタグの中で書いてスタイルを実装します。
https://mui.com/system/getting-started/the-sx-prop/

MUI公式のスタイルエンジンについて書いているドキュメントです。
https://mui.com/material-ui/guides/styled-engine/

疑問

間違ったことは言ってないか?

k_yask_yas

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現在)

FYI

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

React Hook Formまとめ記事
https://reffect.co.jp/react/react-hook-form

ReactでFormを検討する
https://zenn.dev/longbridge/articles/648d6b6c499eef

k_yask_yas

Stepper の実装

MUIのコンポーネントを使用します。

コード全体
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>
  );
}

k_yask_yas

Stepper基本的な動きの理解

解説するコード

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

export default function Hoge() {
  const [activeStep, setActiveStep] = useState(0);

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

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

  const handleReset = () => {
    setActiveStep(0);
  };
  return (
    <Container>
      <Box sx={{ width: "100%" }}>
        <Stepper activeStep={activeStep}>
          {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 }}>
              <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" }} />
              <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);
  const handleNext = () => {
    setActiveStep((prevActiveStep) => prevActiveStep + 1);
  };

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

  const handleReset = () => {
    setActiveStep(0);
  };
~~~
        {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" }} />
              <Button onClick={handleNext}>
                {activeStep === steps.length - 1 ? "Finish" : "Next"}
              </Button>
            </Box>
          </div>
        )}

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

疑問

  const [activeStep, setActiveStep] = useState(0);
  const handleNext = () => {
    setActiveStep((prevActiveStep) => prevActiveStep + 1);
  };

setActiveStep(activeStep + 1)でも大丈夫なはずです。上の書き方と何が違うんですか?
この渡し方が変な感じがします。
setActiveStepで引数として渡されるものは現在のactiveStep(状態変数)であってますか?

k_yask_yas

activeStepは変化するから 動き方を固定した方がいいので、上の書き方がBetter

k_yask_yas

ステップで、項目名にオプション名を入れたい場合

上記のStepperタグのコンテンツを下記に変更する

~~~
        <Stepper activeStep={activeStep}>
          {/* {steps.map((label) => (
            <Step key={label}>
              <StepLabel>{label}</StepLabel>
            </Step>
          ))} */}
          {steps.map((label) => {
            const labelProps: {
              optional?: React.ReactNode;
            } = {};
            labelProps.optional = (
              <Typography variant="caption">オプション</Typography>
            );
            return (
              <Step key={label}>
                <StepLabel {...labelProps}>{label}</StepLabel>
              </Step>
            );
          })}
        </Stepper>
~~~

labelPropsという名前でoptionalというプロパティを持つオブジェクトを作成します。
optionalプロパティのValueにreactエレメントを代入します。
StepLabelタグの属性に{...labelProps}というスプレッド構文を使ったオブジェクトを渡すことによって、StepLabelタグにオプション要素を渡すことができます。

FYI
StepLabelのAPI propsの種類
https://mui.com/material-ui/api/step-label/

スプレッド構文
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Spread_syntax

疑問

{...labelProps}は
optional:( <Typography variant="caption">オプション</Typography> )と同義ですか?

k_yask_yas

ステップのスキップ処理を実装したい

上記の「Stepper基本的な動きの理解」記事のStepperタグのコンテンツを下記に変更する。
新しい状態変数と関数を追加した。

  const [skipped, setSkipped] = useState(new Set<number>());

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

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

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

  const handleSkip = () => {
    setActiveStep((prevActiveStep) => prevActiveStep + 1);
    setSkipped((prevSkipped) => {
      const newSkipped = new Set(prevSkipped.values());
      newSkipped.add(activeStep);
      return newSkipped;
    });
  };
~~~
        <Stepper activeStep={activeStep}>
          {/* {steps.map((label) => (
            <Step key={label}>
              <StepLabel>{label}</StepLabel>
            </Step>
          ))} */}
          {steps.map((label, index) => {
            const stepProps: { completed?: boolean } = {};
            if (isStepSkipped(index)) {
              stepProps.completed = false;
            }
            return (
              <Step key={label} {...stepProps}>
                <StepLabel>{label}</StepLabel>
              </Step>
            );
          })}
        </Stepper>

動き.gif

解説

スキップボタンを押すことによってlabelのチェックマークをつけずに次のステップに行ける実装を行ないました。
Stepタグの属性でcompletedというpropsがあるので、それを利用してステップの数字にチェックをつけないようにします。
状態変数が変わって画面の再レンダリングが行われたときにskippedのコレクションを確認して、値があればcompledがfalseになり、チェックマークがつかないようになります。

↓細かく見ていきます。

  • Skiped状態変数について
  const [skipped, setSkipped] = useState(new Set<number>());

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

Setオブジェクトを持ったskippedという状態変数を作成している。(Setオブジェクトは一意の値を格納するオブジェクトである。)

  • handleNext関数について
  const handleNext = () => {
    let newSkipped = skipped;
    if (isStepSkipped(activeStep)) {
      newSkipped = new Set(newSkipped.values());
      newSkipped.delete(activeStep);
    }

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

ユーザがスキップした箇所に戻ってき、フォームを入力した際に「次へ」を押すとステップの状態を完了にしたいのでSetオブジェクト.delete()でSetオブジェクトの中身を消しています。
setSkippedでSetオブジェクトの中身を更新したskippedを新しい状態変数としてセットすることで、ステップフォームの状態を更新しています。

  • handleSkipについて
  const handleSkip = () => {
    setActiveStep((prevActiveStep) => prevActiveStep + 1);
    setSkipped((prevSkipped) => {
      const newSkipped = new Set(prevSkipped.values());
      newSkipped.add(activeStep);
      return newSkipped;
    //   return prevSkipped.add(activeStep);
    });
  };

スキップした時に、skipped状態変数を更新し、SetオブジェクトにスキップしたStepの番号を入れて、ステップの状態を更新している。

FYI

Setオブジェクトについて
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Set

Step API
https://mui.com/material-ui/api/step/

疑問

クラスは毎回newしないといけないですか?下記の書き方じゃ問題がありますか?

    setSkipped((prevSkipped) => {
      const newSkipped = new Set(prevSkipped.values());
      newSkipped.add(activeStep);
      return newSkipped;
    //   return prevSkipped.add(activeStep);
    });
k_yask_yas

ステップのラベルの位置を変更したい

<Stepper activeStep={activeStep}> { /*Before*/}<Stepper activeStep={activeStep} alternativeLabel> { /*After*/}

Stepperタグの属性にalternativeLabelを追加します。


Before


After

FYI

Stepper API
https://mui.com/material-ui/api/stepper/

k_yask_yas

コンポーネントに分けてステップフォーム切り替えを行う

コンポーネントを作成します。
switch文を使ってステップごとに表示させるコンポーネントの切り替えを行います。(今のところ各コンポーネントは文字列を返却するだけです。)

コード全文
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 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 />;
      case 1:
        return <Optional />;
      case 2:
        return <Confirm />;
    }
  };
  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 }}>
              <Box sx={{ flex: "1 1 auto" }} />
              <Button onClick={handleReset}>Reset</Button>
            </Box>
          </div>
        ) : (
          <div>
            <Typography sx={{ mt: 2, mb: 1 }}>Step {activeStep + 1}</Typography>
            {changeFormConponent(activeStep)}
            <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" }} />
              <Button onClick={handleNext}>
                {activeStep === steps.length - 1 ? "Finish" : "Next"}
              </Button>
            </Box>
          </div>
        )}
      </Box>
    </Container>
  );
}
  const changeFormConponent = (activeStep: number) => {
    switch (activeStep) {
      case 0:
        return <Basic />;
      case 1:
        return <Optional />;
      case 2:
        return <Confirm />;
    }
  };
~~~
// リターン React Elementsの中で記入
{changeFormConponent(activeStep)}

疑問

この書き方であっていますか?他にいい方法はないですか?
関数の引数の命名が難しいので、何かいい方法はありますか?

k_yask_yas

if文でもいい 見やすい elseとかdefaultみたい
if文で書いたほう

switchMainFormとか動きがわかればいい

k_yask_yas

React Hook Formを用いてフォーム実装を行う

Basix.tsxを作成して、フォームを作っていきます。
名前入力欄を作成し、次へのボタンを押すとコンソールに入力したデータが表示されるという実装です。

コード全文
import { Box, Button, TextField } from "@mui/material";
import { Controller, useForm } from "react-hook-form";

export interface MyformBasic {
  nameBox: string;
}

interface Props {
  handleNext : Function
}

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

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

  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;

解説

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 embraces uncontrolled components and native inputs, however it's hard to avoid working with external controlled component such as React-Select, AntD and MUI. This wrapper component will make it easier for you to work with them.
React Hook Form は非制御コンポーネントとネイティブ入力を受け入れますが、React-Select、AntD、MUI などの外部制御コンポーネントの使用を避けるのは困難です。このラッパー コンポーネント(Controller)を使用すると、それらを簡単に操作できます。
https://react-hook-form.com/api/usecontroller/controller

以下細かい部分を見ていきます。↓

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

RHFのuseForm APIを使用して、フォームの管理を行なっています。引数にオブジェクトを渡すことでデフォルトの値などを指定できます。useFormを実行するとpropsを返却します。

Props↓

  • 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 コンポーネントは、RHFにおいて、Controlled Componentで使用するコンポーネントです。
Controller コンポーネントの属性を見ていきます。

  • name 一意のフォームの名前 フォームの要素を取得するときにnameで指定した文字列を呼び出します
  • control useFormで返却したpropsのcontrolオブジェクトを入れてこのインプット要素をuseFormに登録します
  • render レンダープロップスです。reactエレメントを返却する関数です。返却するreactエレメントにRHFのイベントや値を提供して返却します。上記では、fieldオブジェクトをTextFieldタグに渡している。

FYI

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

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

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

レンダープロップス
https://ja.reactjs.org/docs/render-props.html

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

疑問

Controlled Uncontrolledについて理解はあっていますか?間違ったことは言ってないですか?
useFormを実行するとpropsを返却します。←ここでいうpropsっていうのは何ですか?

interface Props {
  handleNext : Function
}

プロップスの型定義の仕方がわからないです

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

  <form onSubmit={handleSubmit(onSubmit)}>

この関数実行のやり方であってますか?
引数にdataを渡していないのに、console.logが動作するのが、なんとも言えないです。

k_yask_yas

プロパティ付きのオブジェクトを返す。その中のプロパティにアクセスできる
オーバーロード=型の定義がうまく行ってない

k_yask_yas

useStateでStepフォームで値を保持する

親コンポーネントで状態を管理して、複数の子コンポーネントにpropsとして状態を渡すuseStateを使った実装を行います。(formの部分だけで使うので、useContextは使わないです。アプリケーションの広範囲でStateを使いたい場合は導入を検討します)

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;
Optional.tsx全文
Optional.tsx
import { Box, Button, TextField } from "@mui/material";
import { Controller, useForm } from "react-hook-form";

export interface MyformOptional {
  optionalBox: string;
}

function Optional(props: any) {
  const { control, handleSubmit, setValue } = useForm<MyformOptional>({
    defaultValues: {
      optionalBox: "",
    },
  });

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

  return (
    <div>
      <form onSubmit={handleSubmit(onSubmit)}>
        <Controller
          name="optionalBox"
          control={control}
          render={({ field }) => (
            <TextField
              {...field}
              type="text"
              label="備考"
              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;

以下解説

  const [formValue, setFormValue] = useState({});

  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}
          />
        );
    }
  };

親コンポーネントから子コンポーネントにpropsで状態変数と状態を更新する関数を渡しています。

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

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

FYI

状態管理のReact Hooks

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

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

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

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

valueの持たせ方
https://zenn.dev/sora_kumo/articles/72fae8a8244adf

疑問

Contextはいつ使うのか。
仮説:アプリケーション全体で値(State)を保持したい時

k_yask_yas

認証とかアプリケーション全体で使う時しかほとんどない

基本的にuseStateで行えるときは行う。

k_yask_yas

Formが戻ったときにステップフォームの値を保持する

React Hook FormのSetValueを使用して、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関数を実行して、既存のフォームの値を代入しています。

FYI

https://react-hook-form.com/api/useform/setvalue

k_yask_yas

Formにバリデーションを追加します。

Yupを使用して、React Hook Formのバリデーションを行う。挙動としては、フォームのインプット要素にカーソルをおいて離すとバリデーションが走る。バリデーションエラーが起きているときは、サブミットできないようになっている

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(yupResolver(schema));

  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;
npm install @hookform/resolvers yup

まずyupをインストールします。

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プロパティに渡すことで、React Hook Formと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プロパティを呼び出して使用します。

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

FYI

https://react-hook-form.com/get-started#schemavalidation
https://github.com/jquense/yup

MUIのTextField API
https://mui.com/material-ui/api/text-field/

yupのshapeの意味
https://github.com/jquense/yup#objectshapefields-object-nosortedges-arraystring-string-schema

k_yask_yas

yupを使うメリットはバリデーションルールをフォームとは切り離せるところにある?
高い拡張性?

このスクラップは2022/12/08にクローズされました