TOOLS BOX/ガイド/useFormContext と Context を使ったフォーム状態の配布パターン
Concept

useFormContext と Context を使ったフォーム状態の配布パターン

分割フォームコンポーネントにおける状態配布の責務分離を整理するガイド。react-hook-form の FormProvider / useFormContext はフォーム内部状態(入力値・errors・register)の配布に使い、ステップ番号・表示モード・外側の UI 状態は通常の React Context や useReducer が担う。useFormContext を使いすぎてフォーム外の文脈を混入させないための判断基準を説明する。

react-hook-formuseFormContextFormProvidercontextformstate-managementprops-drillingreact

どういう場面で使うか

  • ·FormProvider / useFormContext: フォームを複数コンポーネントに分割し、register / errors / watch を深い階層に渡したいとき
  • ·FormProvider / useFormContext: フォームフィールドが独立したコンポーネントになっており、props drilling を避けたいとき
  • ·通常の Context: ステップ番号・表示モード・フォームと独立した UI フロー状態を配布したいとき
  • ·通常の Context: フォームの外に存在するコンポーネントが状態を必要とするとき

注意点 / Pitfalls

  • ·useFormContext をフォームツリー外のコンポーネントで呼ぶと、FormProvider 外での使用エラーになる。フォーム内部専用として扱う
  • ·ステップ遷移・送信フロー状態・表示モードを useFormContext 経由で渡すと、フォーム内部状態と UI フロー状態が混在して責務が崩れる
  • ·useFormContext を使うコンポーネントは FormProvider の子ツリーに強く依存する。フォーム外での再利用が難しくなる
  • ·通常の Context に頻繁に変わる値を入れると、Provider の子ツリー全体が再レンダリングされる。フォーム入力値を通常の Context で配布しない
  • ·useFormContext の watch() を多用すると、watch する値が変わるたびにそのコンポーネントが再レンダリングされる

何と混同しやすいか

補足

useFormContext はフォームの内側に閉じた状態配布の手段。フォーム外の文脈(ページのモード・ステップ管理など)は通常の Context や useReducer と分けて管理する。「そのコンポーネントはフォームがなくても成立するか」を判断基準にすると責務が明確になる。

責務の分け方

FormProvider / useFormContext が担う(フォームの内側):
  ・register — フィールドの登録
  ・errors   — バリデーションエラー
  ・watch    — フィールド値の購読
  ・setValue / getValues / reset などのフォーム操作
  ・formState(isDirty / isSubmitting / isValid など)

通常の Context / useReducer が担う(フォームを取り巻く UI):
  ・ステップ番号(step1 / step2 / confirm)
  ・表示モード(編集モード / 確認モード)
  ・送信フロー状態(idle / loading / success / error)
  ・フォームとは独立したユーザー設定や権限情報

判断の起点: 「そのコンポーネントは FormProvider がなくても成立するか?」
  No(フォームフィールドそのもの)→ useFormContext
  Yes(ステップ表示・ヘッダーなど)→ 通常の Context か props

FormProvider / useFormContext が向く場面

// 深い階層のフォームフィールドに props でひたすら渡すパターン
// ❌ props drilling
function AddressForm({ register, errors }: { register: UseFormRegister<FormData>; errors: FieldErrors<FormData> }) {
  return <ZipCodeField register={register} errors={errors} />;
}
function ZipCodeField({ register, errors }: { register: UseFormRegister<FormData>; errors: FieldErrors<FormData> }) {
  return (
    <>
      <input {...register("zipCode", { required: "必須" })} />
      {errors.zipCode && <p>{errors.zipCode.message}</p>}
    </>
  );
}
// ✅ FormProvider で渡して useFormContext で受け取る
import { useForm, FormProvider, useFormContext } from "react-hook-form";

type FormData = {
  name: string;
  zipCode: string;
  address: string;
};

function RegistrationForm() {
  const methods = useForm<FormData>({ defaultValues: { name: "", zipCode: "", address: "" } });

  function onSubmit(data: FormData) {
    console.log(data);
  }

  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(onSubmit)}>
        <NameField />
        <AddressSection />
        <button type="submit">登録</button>
      </form>
    </FormProvider>
  );
}

function NameField() {
  const { register, formState: { errors } } = useFormContext<FormData>();
  return (
    <>
      <input {...register("name", { required: "必須" })} />
      {errors.name && <p>{errors.name.message}</p>}
    </>
  );
}

function AddressSection() {
  return (
    <div>
      <ZipCodeField />
      <AddressField />
    </div>
  );
}

function ZipCodeField() {
  const { register, formState: { errors } } = useFormContext<FormData>();
  return (
    <>
      <input {...register("zipCode", { required: "必須" })} />
      {errors.zipCode && <p>{errors.zipCode.message}</p>}
    </>
  );
}

function AddressField() {
  const { register } = useFormContext<FormData>();
  return <input {...register("address")} />;
}

通常の Context と useFormContext を併用する

// フォーム内部状態 → useFormContext
// UI フロー状態(ステップ・モード)→ 通常の Context

import { createContext, useContext, useReducer } from "react";
import { useForm, FormProvider, useFormContext } from "react-hook-form";

// --- UI フロー用 Context ---
type WizardState = { step: "info" | "address" | "confirm" };
type WizardAction = { type: "NEXT" } | { type: "BACK" };

const WizardContext = createContext<{
  state: WizardState;
  dispatch: React.Dispatch<WizardAction>;
} | null>(null);

function wizardReducer(state: WizardState, action: WizardAction): WizardState {
  const steps: WizardState["step"][] = ["info", "address", "confirm"];
  const idx = steps.indexOf(state.step);
  if (action.type === "NEXT") return { step: steps[Math.min(idx + 1, steps.length - 1)] };
  return { step: steps[Math.max(idx - 1, 0)] };
}

function useWizard() {
  const ctx = useContext(WizardContext);
  if (!ctx) throw new Error("WizardContext missing");
  return ctx;
}

// --- フォーム本体 ---
type FormData = { name: string; zipCode: string; address: string };

function WizardForm() {
  const methods = useForm<FormData>({ defaultValues: { name: "", zipCode: "", address: "" } });
  const [wizardState, dispatch] = useReducer(wizardReducer, { step: "info" });

  async function onSubmit(data: FormData) {
    await api.register(data);
  }

  return (
    // フォーム内部状態は FormProvider で配布
    <FormProvider {...methods}>
      {/* UI フロー状態は WizardContext で配布 */}
      <WizardContext.Provider value={{ state: wizardState, dispatch }}>
        <form onSubmit={methods.handleSubmit(onSubmit)}>
          <WizardStep />
        </form>
      </WizardContext.Provider>
    </FormProvider>
  );
}

// ステップ表示コンポーネント(UI フロー状態を参照)
function WizardStep() {
  const { state } = useWizard();

  if (state.step === "info") return <InfoStep />;
  if (state.step === "address") return <AddressStep />;
  return <ConfirmStep />;
}

// フォームフィールド(フォーム内部状態を参照)
function InfoStep() {
  const { register, formState: { errors } } = useFormContext<FormData>();
  const { dispatch } = useWizard();

  return (
    <div>
      <input {...register("name", { required: "必須" })} />
      {errors.name && <p>{errors.name.message}</p>}
      <button type="button" onClick={() => dispatch({ type: "NEXT" })}>次へ</button>
    </div>
  );
}

// 確認画面(フォーム値の参照のみ)
function ConfirmStep() {
  const { getValues } = useFormContext<FormData>();
  const { dispatch } = useWizard();
  const values = getValues();

  return (
    <div>
      <p>名前: {values.name}</p>
      <p>郵便番号: {values.zipCode}</p>
      <button type="button" onClick={() => dispatch({ type: "BACK" })}>戻る</button>
      <button type="submit">送信</button>
    </div>
  );
}

useFormContext を使いすぎて責務が崩れるケース

// ❌ ステップ番号やモードを useFormContext 経由で渡そうとするパターン
// → フォーム内部状態と UI フロー状態が混在する

type FormData = {
  name: string;
  // ❌ フォームデータではない UI 状態をフォームに混入
  currentStep: number;
  displayMode: "edit" | "confirm";
};

function BadForm() {
  const methods = useForm<FormData>({
    defaultValues: { name: "", currentStep: 1, displayMode: "edit" },
  });

  return (
    <FormProvider {...methods}>
      <StepDisplay />  {/* currentStep を useFormContext で受け取ろうとする */}
    </FormProvider>
  );
}

function StepDisplay() {
  const { watch, setValue } = useFormContext<FormData>();
  const currentStep = watch("currentStep"); // ❌ UI フロー状態を form で管理している

  return (
    <div>
      <p>ステップ {currentStep}</p>
      <button onClick={() => setValue("currentStep", currentStep + 1)}>次へ</button>
    </div>
  );
}

// ✅ UI フロー状態は別の Context か useReducer で管理する(上の WizardForm 参照)
// ❌ フォーム外のコンポーネントで useFormContext を使う
function PageHeader() {
  // FormProvider の外にあるヘッダーで useFormContext を呼ぶと実行時エラーになる
  const { formState: { isDirty } } = useFormContext(); // ❌ Error: FormProvider missing
  return <header>{isDirty ? "未保存" : "保存済み"}</header>;
}

// ✅ 必要なら props で受け取るか、isDirty を useState で別管理する
function PageHeader({ isDirty }: { isDirty: boolean }) {
  return <header>{isDirty ? "未保存" : "保存済み"}</header>;
}

watch の再レンダリングに注意

// ❌ watch() 全体を購読すると、どのフィールドが変わっても再レンダリングされる
function ExpensiveField() {
  const { watch } = useFormContext<FormData>();
  const allValues = watch(); // 全フィールドを購読
  // → いずれかのフィールドが変わるたびに再レンダリング
}

// ✅ 必要なフィールドだけを指定して購読する
function ZipCodeWatcher() {
  const { watch } = useFormContext<FormData>();
  const zipCode = watch("zipCode"); // zipCode が変わったときだけ再レンダリング

  // 郵便番号から住所を自動補完するような処理
  useEffect(() => {
    if (zipCode.length === 7) autoFillAddress(zipCode);
  }, [zipCode]);

  return null;
}

よくある誤用まとめ

誤用正しい判断
ステップ番号を useFormContext 経由で渡すステップ番号は通常の Context か useReducer で管理
フォームの外のコンポーネントで useFormContext を呼ぶFormProvider の子ツリー内でのみ使う
UI フロー状態を FormData 型に混入するフォームデータはサーバーに送る値だけにとどめる
watch() 全体を購読するwatch("fieldName") でフィールドを絞る
useFormContext を Zustand の代替として使うフォーム外の共有は Zustand か通常の Context

フォーム内部状態と共有状態の境界 → Zustand と react-hook-form の境界

useReducer との役割分担 → useReducer と react-hook-form の使い分け

React のデータ受け渡しパターン全体像 → React Hook によるデータ受け渡しパターン

関連ドキュメント

関連サンプル

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