責務の分け方
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 によるデータ受け渡しパターン