TOOLS BOX/ガイド/useReducer と react-hook-form の使い分け
Concept

useReducer と react-hook-form の使い分け

フォーム実装における useReducer と react-hook-form の役割分担を整理するガイド。入力値・バリデーション・dirty state は react-hook-form が担い、ステップ遷移・画面モード・送信フロー状態・複数イベント分岐は useReducer が向く。両者を混同せず、入力値と UI フロー状態を明確に分ける実務判断基準を説明する。

useReducerreact-hook-formformstate-managementmulti-stepform-stateui-statereducerreact

どういう場面で使うか

  • ·react-hook-form: 入力値・バリデーションエラー・isDirty・isSubmitting などフォーム内部状態
  • ·react-hook-form: 入力ごとに即時フィードバックが必要なバリデーション
  • ·useReducer: ステップ遷移(step1 → step2 → confirm → done)などの画面フロー管理
  • ·useReducer: 送信中・送信成功・送信失敗などの非同期フロー状態
  • ·useReducer: 編集モード・確認モードなど、フォーム全体の表示モード切替
  • ·両者の併用: フォーム入力値は react-hook-form、UI フロー状態は useReducer で並列管理

注意点 / Pitfalls

  • ·reducer にフォーム入力値そのものを持つと、キーストロークごとに dispatch が走りバリデーション・dirty 追跡を自前実装することになる
  • ·react-hook-form だけで複雑なステップ遷移を管理しようとすると、フォームの外にある画面制御が form の state に混入する
  • ·送信フロー状態(loading/success/error)を react-hook-form の isSubmitting だけで表現しようとすると、成功後の表示切替や再送信判断が難しくなる
  • ·useReducer でフォーム全体を置き換えようとすると、バリデーション・reset・touched の管理コストが react-hook-form より大きくなる
  • ·multi-step form でステップデータをすべて reducer に入れると、各ステップ内のバリデーションも手動実装が必要になる

何と混同しやすいか

補足

react-hook-form は「フォームの内側」を担い、useReducer は「フォームを取り巻く UI フロー」を担う。入力値と UI フロー状態は別の責務であり、どちらか一方に統一する必要はない。両者を並列に使うことが複雑なフォーム実装のベストプラクティスになる。

責務の分け方

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 バリデーションパターン

関連ドキュメント

関連サンプル

同じテーマや技術スタックを使った実装例