責務の分け方
react-hook-form が担う(フォームの内側):
・入力値(register / Controller で管理)
・バリデーションエラー(errors)
・フィールドの dirty / touched 状態
・送信中フラグ(isSubmitting)
・フォーム全体の valid / invalid 状態
・reset・setValue などの操作
useReducer が担う(フォームを取り巻く UI フロー):
・ステップ遷移(step1 → step2 → confirm → done)
・画面モード切替(表示モード ↔ 編集モード)
・非同期フロー状態(idle → loading → success / error)
・複数イベントによる表示分岐(キャンセル・戻る・スキップなど)
判断の起点: 「その状態は入力値か、UI の表示制御か?」
入力値・バリデーション → react-hook-form
画面の状態・フロー遷移 → useReducer
react-hook-form が向く場面
// ✅ 入力値・バリデーション・dirty は react-hook-form に任せる
function ProfileForm() {
const {
register,
handleSubmit,
formState: { errors, isDirty, isSubmitting },
reset,
} = useForm<ProfileData>({
defaultValues: { name: "", email: "" },
});
async function onSubmit(data: ProfileData) {
await api.updateProfile(data);
reset(); // フォームをリセット
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name", { required: "必須" })} />
{errors.name && <p>{errors.name.message}</p>}
<input {...register("email", { pattern: { value: /\S+@\S+/, message: "形式が正しくありません" } })} />
{errors.email && <p>{errors.email.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "送信中..." : "保存"}
</button>
{isDirty && <p>未保存の変更があります</p>}
</form>
);
}
useReducer が向く場面
非同期フロー状態(送信 → 成功 / 失敗)
// ✅ 送信フロー状態を useReducer で管理する
type SubmitState =
| { status: "idle" }
| { status: "loading" }
| { status: "success" }
| { status: "error"; message: string };
type SubmitAction =
| { type: "SUBMIT" }
| { type: "SUCCESS" }
| { type: "ERROR"; message: string }
| { type: "RESET" };
function submitReducer(state: SubmitState, action: SubmitAction): SubmitState {
switch (action.type) {
case "SUBMIT": return { status: "loading" };
case "SUCCESS": return { status: "success" };
case "ERROR": return { status: "error", message: action.message };
case "RESET": return { status: "idle" };
}
}
function ContactForm() {
const [submitState, dispatch] = useReducer(submitReducer, { status: "idle" });
const { register, handleSubmit, reset, formState: { errors } } = useForm<ContactData>();
async function onSubmit(data: ContactData) {
dispatch({ type: "SUBMIT" });
try {
await api.sendContact(data);
dispatch({ type: "SUCCESS" });
reset();
} catch (e) {
dispatch({ type: "ERROR", message: "送信に失敗しました" });
}
}
if (submitState.status === "success") {
return (
<div>
<p>送信完了しました</p>
<button onClick={() => dispatch({ type: "RESET" })}>もう一度送る</button>
</div>
);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("message", { required: "必須" })} />
{errors.message && <p>{errors.message.message}</p>}
{submitState.status === "error" && <p>{submitState.message}</p>}
<button type="submit" disabled={submitState.status === "loading"}>
{submitState.status === "loading" ? "送信中..." : "送信"}
</button>
</form>
);
}
画面モード切替(表示 ↔ 編集)
type ViewMode = "view" | "edit";
type ModeAction = { type: "START_EDIT" } | { type: "CANCEL" } | { type: "SAVED" };
function modeReducer(state: ViewMode, action: ModeAction): ViewMode {
switch (action.type) {
case "START_EDIT": return "edit";
case "CANCEL":
case "SAVED": return "view";
}
}
function UserCard({ user }: { user: User }) {
const [mode, dispatch] = useReducer(modeReducer, "view");
const { register, handleSubmit, reset } = useForm<UserData>({
defaultValues: { name: user.name, email: user.email },
});
async function onSubmit(data: UserData) {
await api.updateUser(data);
dispatch({ type: "SAVED" }); // 保存成功で表示モードへ
}
function handleCancel() {
reset(); // フォームをリセット
dispatch({ type: "CANCEL" });
}
if (mode === "view") {
return (
<div>
<p>{user.name}</p>
<button onClick={() => dispatch({ type: "START_EDIT" })}>編集</button>
</div>
);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name", { required: true })} />
<button type="submit">保存</button>
<button type="button" onClick={handleCancel}>キャンセル</button>
</form>
);
}
両者を併用する — multi-step form
// ステップ遷移は useReducer、各ステップの入力は react-hook-form
type WizardStep = "profile" | "address" | "confirm" | "done";
type WizardAction =
| { type: "NEXT" }
| { type: "BACK" }
| { type: "COMPLETE" };
const stepOrder: WizardStep[] = ["profile", "address", "confirm", "done"];
function wizardReducer(state: WizardStep, action: WizardAction): WizardStep {
const idx = stepOrder.indexOf(state);
switch (action.type) {
case "NEXT": return stepOrder[Math.min(idx + 1, stepOrder.length - 1)];
case "BACK": return stepOrder[Math.max(idx - 1, 0)];
case "COMPLETE": return "done";
}
}
function RegistrationWizard() {
const [step, dispatch] = useReducer(wizardReducer, "profile");
// 各ステップのデータは送信後に store や state へ
const [collectedData, setCollectedData] = useState<Partial<RegistrationData>>({});
if (step === "profile") {
return (
<ProfileStep
onNext={(data) => {
setCollectedData((prev) => ({ ...prev, ...data }));
dispatch({ type: "NEXT" });
}}
/>
);
}
if (step === "address") {
return (
<AddressStep
onNext={(data) => {
setCollectedData((prev) => ({ ...prev, ...data }));
dispatch({ type: "NEXT" });
}}
onBack={() => dispatch({ type: "BACK" })}
/>
);
}
if (step === "confirm") {
return (
<ConfirmStep
data={collectedData}
onSubmit={async () => {
await api.register(collectedData);
dispatch({ type: "COMPLETE" });
}}
onBack={() => dispatch({ type: "BACK" })}
/>
);
}
return <p>登録完了しました</p>;
}
// 各ステップは react-hook-form で入力・バリデーションを担う
function ProfileStep({ onNext }: { onNext: (data: ProfileData) => void }) {
const { register, handleSubmit, formState: { errors } } = useForm<ProfileData>();
return (
<form onSubmit={handleSubmit(onNext)}>
<input {...register("name", { required: "必須" })} />
{errors.name && <p>{errors.name.message}</p>}
<button type="submit">次へ</button>
</form>
);
}
reducer にフォーム入力値を持たない理由
// ❌ reducer に入力値を持つパターン
type FormState = {
name: string;
email: string;
step: "edit" | "confirm";
};
type FormAction =
| { type: "SET_NAME"; value: string }
| { type: "SET_EMAIL"; value: string }
| { type: "GO_CONFIRM" };
// → キーストロークごとに dispatch → バリデーション・dirty・touched の自前実装が必要
// → reset でフォームを元の状態に戻す処理も自前で書くことになる
// ✅ 入力値は react-hook-form、フロー状態だけ reducer が持つ
type FlowState = { step: "edit" | "confirm" };
type FlowAction = { type: "GO_CONFIRM" } | { type: "BACK_TO_EDIT" };
function EditForm() {
const [flow, dispatch] = useReducer(
(s: FlowState, a: FlowAction): FlowState => {
if (a.type === "GO_CONFIRM") return { step: "confirm" };
return { step: "edit" };
},
{ step: "edit" }
);
const { register, handleSubmit, getValues, formState: { errors } } = useForm<FormData>();
function onNext(data: FormData) {
dispatch({ type: "GO_CONFIRM" }); // 入力値は getValues() / handleSubmit で取得
}
if (flow.step === "confirm") {
const values = getValues();
return (
<div>
<p>名前: {values.name}</p>
<button onClick={() => dispatch({ type: "BACK_TO_EDIT" })}>戻る</button>
<button onClick={() => api.submit(values)}>送信</button>
</div>
);
}
return (
<form onSubmit={handleSubmit(onNext)}>
<input {...register("name", { required: "必須" })} />
{errors.name && <p>{errors.name.message}</p>}
<button type="submit">確認へ</button>
</form>
);
}
よくある誤用まとめ
| 誤用 | 正しい判断 |
|---|---|
reducer に name / email などの入力値を持つ | 入力値は react-hook-form の register に任せる |
isSubmitting だけで送信後の success / error 画面を切り替えようとする | 送信フロー状態は useReducer で管理する |
| multi-step form のすべてのデータを reducer に入れる | 各ステップの入力中データは react-hook-form、完了データだけ外部に渡す |
| useReducer でフォームを再実装して react-hook-form を排除する | バリデーション・dirty・reset コストが大きくなる |
| react-hook-form だけでステップ遷移を管理する | フォームの外の画面制御が form state に混入する |
useReducer 単体の使い分け → useReducer と useState の使い分け
フォーム内部状態と共有状態の境界 → Zustand と react-hook-form の境界
Server Actions との接続 → Server Actions + Zod バリデーションパターン