レビュー済み·難易度: 中級·更新: 2026-04-18

useReducer でフォームの状態とバリデーションを一元管理する

useReducer を使い、複数フィールドのフォーム状態・入力値・バリデーションエラーを単一の reducer で管理する例。外部ライブラリなしで複雑なフォームを扱う。

nextjsformstate-management

対応バージョン

nextjs 15react 19

前提環境

React の useState と useReducer の基本を理解していること

概要

useReducer を使い、複数フィールドの入力値・タッチ状態・バリデーションエラーを単一の reducer で管理するフォームを実装する。react-hook-form を使わずに仕組みを理解する実装例。フィールドが増えても useState を分散させずに済む。

インストール

# 追加インストールは不要

実装

フォームの型と reducer

// components/RegistrationForm.tsx
"use client";

import { useReducer } from "react";

type FormFields = {
  name: string;
  email: string;
  password: string;
};

type FormErrors = Partial<Record<keyof FormFields, string>>;

type FormState = {
  values: FormFields;
  errors: FormErrors;
  touched: Partial<Record<keyof FormFields, boolean>>;
  isSubmitting: boolean;
};

type FormAction =
  | { type: "CHANGE"; field: keyof FormFields; value: string }
  | { type: "BLUR"; field: keyof FormFields }
  | { type: "SUBMIT_START" }
  | { type: "SUBMIT_END" }
  | { type: "RESET" };

const initialState: FormState = {
  values: { name: "", email: "", password: "" },
  errors: {},
  touched: {},
  isSubmitting: false,
};

function validate(values: FormFields): FormErrors {
  const errors: FormErrors = {};
  if (!values.name.trim()) errors.name = "名前は必須です";
  if (!values.email.trim()) {
    errors.email = "メールアドレスは必須です";
  } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) {
    errors.email = "正しいメールアドレスを入力してください";
  }
  if (values.password.length < 8) errors.password = "8文字以上で入力してください";
  return errors;
}

function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case "CHANGE": {
      const values = { ...state.values, [action.field]: action.value };
      const errors = state.touched[action.field]
        ? validate(values)
        : state.errors;
      return { ...state, values, errors };
    }
    case "BLUR": {
      const touched = { ...state.touched, [action.field]: true };
      const errors = validate(state.values);
      return { ...state, touched, errors };
    }
    case "SUBMIT_START":
      return {
        ...state,
        touched: { name: true, email: true, password: true },
        errors: validate(state.values),
        isSubmitting: true,
      };
    case "SUBMIT_END":
      return { ...state, isSubmitting: false };
    case "RESET":
      return initialState;
    default:
      return state;
  }
}

フォームコンポーネント

export function RegistrationForm() {
  const [state, dispatch] = useReducer(formReducer, initialState);
  const { values, errors, touched, isSubmitting } = state;

  const hasErrors = Object.keys(validate(values)).length > 0;

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    dispatch({ type: "SUBMIT_START" });
    if (hasErrors) {
      dispatch({ type: "SUBMIT_END" });
      return;
    }
    // 実際の送信処理をここに書く
    await new Promise((r) => setTimeout(r, 1000));
    alert("登録完了!");
    dispatch({ type: "RESET" });
  }

  return (
    <form onSubmit={handleSubmit} className="mx-auto max-w-md space-y-4 p-8">
      <h2 className="text-xl font-bold">ユーザー登録</h2>

      {(["name", "email", "password"] as const).map((field) => (
        <div key={field}>
          <label className="mb-1 block text-sm font-medium text-gray-700">
            {field === "name" ? "名前" : field === "email" ? "メール" : "パスワード"}
          </label>
          <input
            type={field === "password" ? "password" : field === "email" ? "email" : "text"}
            value={values[field]}
            onChange={(e) =>
              dispatch({ type: "CHANGE", field, value: e.target.value })
            }
            onBlur={() => dispatch({ type: "BLUR", field })}
            className={`w-full rounded border px-3 py-2 text-sm ${
              touched[field] && errors[field]
                ? "border-red-400 focus:ring-red-400"
                : "border-gray-300 focus:ring-blue-500"
            } focus:outline-none focus:ring-2`}
          />
          {touched[field] && errors[field] && (
            <p className="mt-1 text-xs text-red-500">{errors[field]}</p>
          )}
        </div>
      ))}

      <button
        type="submit"
        disabled={isSubmitting}
        className="w-full rounded bg-blue-600 py-2 text-sm font-medium text-white disabled:opacity-50"
      >
        {isSubmitting ? "送信中..." : "登録する"}
      </button>
    </form>
  );
}
// app/page.tsx
import { RegistrationForm } from "@/components/RegistrationForm";

export default function Page() {
  return <RegistrationForm />;
}

ポイント

  • useReducervalues / errors / touched / isSubmitting をひとつの state オブジェクトにまとめることで、関連状態の整合性が保ちやすくなる
  • CHANGE アクションはフィールドが touched になっている場合のみバリデーションを走らせる(入力中にエラーを出しすぎない)
  • BLUR アクションでフィールドを touched にマークし、バリデーションを初めて実行する(ユーザーが離脱したタイミングで表示)
  • SUBMIT_START ですべてのフィールドを touched にすることで、未入力フィールドのエラーも一括表示できる
  • react-hook-form と比べると記述量は増えるが、状態遷移をすべて把握できる。ライブラリの仕組みを理解する土台になる

注意点

react-hook-form を使わずに実装することで、状態管理の仕組みを理解しやすくする。フィールド数が多いフォームで useState の分散を避けたいときに有効。

関連サンプル