2025年5月28日水曜日

【第8回】React Hook Form × MUI:ステップフォームの実装

✅ はじめに

こんにちは!このシリーズでは、React + Vite + TypeScript + MUIを使ってモダンなWebアプリを開発する方法をステップバイステップで解説しています。

第8回となる今回は、React Hook Form と MUI を使って、ユーザーの入力体験を向上させる ステップフォーム(Step Form) を実装します。

フォームを複数のページに分けて入力させることで、長いフォームでもストレスなく入力できる UI を実現します。

📦 使用する技術スタック

ツール バージョン 説明
React Hook Form v7.x 高性能なフォームバリデーションライブラリ
MUI v5.x マテリアルデザインベースのUIコンポーネント
TypeScript 5.x 型安全なJavaScript
Vite 5.x 高速な開発環境とビルドツール

🎯 今回のゴール

  • フォームの入力を段階的に表示
  • 各ステップでバリデーション
  • 最後に確認画面と送信

🚀 実装手順

1. プロジェクトの作成

まずは、Viteを使って新しいプロジェクトを作成します:

npm create vite@latest react-step-form -- --template react-ts
cd react-step-form
npm install

2. 必要なパッケージのインストール

MUIとReact Hook Formをインストールします:

npm install @mui/material @emotion/react @emotion/styled @mui/icons-material react-hook-form

3. プロジェクト構成

今回のプロジェクトは以下のような構成になります:

src/
  ├── components/
  │   ├── StepOne.tsx
  │   ├── StepTwo.tsx
  │   └── Confirmation.tsx
  ├── StepForm.tsx
  └── App.tsx

4. 型定義の作成

まず、共通で使用する型を定義します:

type FormValues = {
  name: string;
  email: string;
};

5. 各ステップコンポーネントの作成

src/components/StepOne.tsx(名前入力):

import { TextField } from "@mui/material";
import { useFormContext } from "react-hook-form";
import type { FC } from "react";
import type { FormValues } from "../types";

const StepOne: FC = () => {
  const {
    register,
    formState: { errors },
  } = useFormContext<FormValues>();

  return (
    <TextField
      fullWidth
      label="名前"
      {...register("name", { required: "名前は必須です" })}
      error={!!errors.name}
      helperText={errors.name?.message}
    />
  );
};

export default StepOne;

src/components/StepTwo.tsx(メール入力):

import { TextField } from "@mui/material";
import { useFormContext } from "react-hook-form";
import type { FC } from "react";
import type { FormValues } from "../types";

const StepTwo: FC = () => {
  const {
    register,
    formState: { errors },
  } = useFormContext<FormValues>();

  return (
    <TextField
      fullWidth
      label="メール"
      {...register("email", {
        required: "メールは必須です",
        pattern: {
          value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
          message: "メール形式が不正です",
        },
      })}
      error={!!errors.email}
      helperText={errors.email?.message}
    />
  );
};

export default StepTwo;

src/components/Confirmation.tsx(確認画面):

import { Typography } from "@mui/material";
import { useFormContext } from "react-hook-form";
import type { FC } from "react";
import type { FormValues } from "../types";

const Confirmation: FC = () => {
  const { getValues } = useFormContext<FormValues>();
  const { name, email } = getValues();

  return (
    <>
      <Typography>名前: {name}</Typography>
      <Typography>メール: {email}</Typography>
    </>
  );
};

export default Confirmation;

6. メインフォームコンポーネントの作成

src/StepForm.tsx

import {
  Box,
  Button,
  Container,
  Step,
  StepLabel,
  Stepper,
  Typography,
} from "@mui/material";
import { useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import StepOne from "./components/StepOne";
import StepTwo from "./components/StepTwo";
import Confirmation from "./components/Confirmation";
import type { FC } from "react";
import type { FormValues } from "./types";

const steps = ["名前入力", "メール入力", "確認"] as const;

const StepForm: FC = () => {
  const methods = useForm<FormValues>({
    defaultValues: {
      name: "",
      email: "",
    },
  });
  const [step, setStep] = useState(0);

  const onNext = async () => {
    const valid = await methods.trigger();
    if (valid) setStep((prev) => prev + 1);
  };

  const onBack = () => setStep((prev) => prev - 1);

  const onSubmit = (data: FormValues) => {
    console.log(JSON.stringify(data, null, 2));
  };

  const renderStep = () => {
    switch (step) {
      case 0:
        return <StepOne />;
      case 1:
        return <StepTwo />;
      case 2:
        return <Confirmation />;
      default:
        return null;
    }
  };

  return (
    <FormProvider {...methods}>
      <Container maxWidth="sm">
        <Box mt={5}>
          <Typography variant="h5" gutterBottom>
            ステップフォーム
          </Typography>

          <Stepper activeStep={step}>
            {steps.map((label) => (
              <Step key={label}>
                <StepLabel>{label}</StepLabel>
              </Step>
            ))}
          </Stepper>

          <Box mt={4} mb={2}>
            <form onSubmit={methods.handleSubmit(onSubmit)}>
              <Box display="flex" flexDirection="column" gap={2}>
                {renderStep()}

                <Box display="flex" justifyContent="space-between">
                  {step > 0 && (
                    <Button variant="outlined" onClick={onBack}>
                      戻る
                    </Button>
                  )}
                  {step < steps.length - 1 ? (
                    <Button variant="contained" onClick={onNext}>
                      次へ
                    </Button>
                  ) : (
                    <Button type="submit" variant="contained" color="primary">
                      送信
                    </Button>
                  )}
                </Box>
              </Box>
            </form>
          </Box>
        </Box>
      </Container>
    </FormProvider>
  );
};

export default StepForm;

7. App.tsxの更新

最後に、src/App.tsxを更新してフォームを表示します:

import StepForm from "./StepForm";
import type { FC } from "react";

const App: FC = () => {
  return <StepForm />;
};

export default App;

🎯 主要なポイント

1. フォーム状態の共有

  • FormProvider: すべてのステップでフォームの状態を共有
  • useFormContext: 子コンポーネントからのフォーム操作を簡単に
  • 型安全性: TypeScriptによる厳密な型チェック

2. ステップ管理

  • useState: 現在のステップを管理
  • trigger(): 次のステップへ進む前にバリデーション
  • 条件付きレンダリング: 現在のステップに応じたコンポーネント表示

3. UI/UX設計

  • Stepper: 進捗状況を視覚的に表示
  • ナビゲーション: 戻る/次へ/送信ボタンの適切な表示
  • バリデーション: 各ステップでの入力チェック

🔚 まとめ

今回は、React Hook Form × MUI を用いて複数ステップで構成されるフォームを作成しました。これにより、ユーザーにとって負担の少ない入力体験が提供できます。

📚 次回予告

【第9回】React Hook Form:フォーム入力内容のローカルストレージ保存