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