This article uses the latest versions of the React Hook Form (hereafter RHF) official docs — Get Started, useForm, register, formState, useController, useWatch, useFormState, useFieldArray, handleSubmit, Form — as the primary source, and is written cross-checking even the type-definition source (the react-hook-form v7.80 line). The goal isn't enumerating APIs but going deep enough that you can judge, in practice, "in which scene, how to write it" yourself.
Versions verified (as of June 2026):
react-hook-formv7 line (latest 7.80),@hookform/resolversv5 line,zodv4. v8 is not out.@hookform/resolversv5 officially supports Zod 4 and Standard Schema (a mechanism to handle Valibot / ArkType etc. through a common interface).
0. The Whole Picture in 30 Seconds: The One Axis Running Through RHF's Design
Capture RHF as a "convenient form library" and you'll get lost in the details. The axis running through it is one — "confine state subscription to the place it's needed, in the amount it's needed."
- Holding input values … lean on uncontrolled (
ref); don't re-render the component while typing. - Validation … delegate to Zod with
zodResolver, consolidating type, rules, and wording into one schema. - State subscription …
formState/watch/useWatch/useFormState"re-render the place that reads them." So where you read becomes a design decision.
Just being conscious of these three avoids almost all the pitfalls discussed later (Proxy subscription, watch's re-render explosion, mistaking Controller). From here we make this axis concrete.
1. Why RHF, and "When Not to Use It"
1-1. Uncontrolled × Re-Render Minimization
Build a form with useState and the whole component re-renders on every single character typed. Harmless with a few fields, but in a business form with dozens of fields, the whole thing re-renders per input, producing perceptible sluggishness and wasted computation.
RHF is uncontrolled-component-first, subscribing to input values directly via ref. The design philosophy the official docs hold up is clear —
- Leverage uncontrolled components and native HTML inputs
- Avoid unnecessary computation (re-rendering)
- Isolate re-rendering only when needed
As a result, hardly any re-render occurs while typing. The validation timing is also controllable with mode, balancing cost and UX.
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
const schema = z.object({
name: z.string().min(2, { error: "2文字以上で入力してください" }),
age: z.number({ error: "数値で入力してください" }).int().min(0),
});
type Schema = z.infer<typeof schema>;
function App() {
const { register, handleSubmit } = useForm<Schema>({
resolver: zodResolver(schema), // 検証は Zod に委譲
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))} noValidate>
<input {...register("name")} />
{/* 数値入力は valueAsNumber で number に変換 */}
<input type="number" {...register("age", { valueAsNumber: true })} />
<button>送信</button>
</form>
);
}
The design starting point: RHF handles "form state," Zod handles "validation rules and types." Make a single schema the Single Source of Truth and type, validation, and messages are unified (DRY). For how to write the Zod side, see the Zod 4 Practical Guide.
1-2. When to Use RHF and When Not To (Decision)
"A form = just use RHF" is actually hasty. With React 19 / Next.js, the options have grown. Choose by requirements.
| Situation | Recommendation | Reason |
|---|---|---|
| A few fields, prioritize JS-less submission, no need for instant-validation UX | Plain form + Server Action (useActionState) | Zero dependencies. Works progressively |
| Medium-to-large, instant / fine-grained validation UX, dynamic fields, controlled-UI-library integration | React Hook Form | Re-render minimization and validation integration shine — the main pick |
| Use multiple frameworks (Solid/Vue, etc.), prioritize type-system-driven most | TanStack Form | Framework-agnostic, strict on types |
| Existing assets using Formik | Migrate to RHF | Formik is maintenance-stalled and controlled-first, with more re-renders |
The crux of the decision is two — (1) do you need instant-validation UX, (2) is the re-render cost a problem at this scale? If both are No, plain form is enough. If either is Yes, RHF is the shortest path.
2. useForm: The Core Settings and Return Values
useForm is called once per form and is the starting point of all operations. Here are the main options, with their practical effect noted.
const {
register, // ネイティブ入力を登録
handleSubmit, // 送信ハンドラを生成
control, // Controller / useFieldArray / useWatch に渡す
formState, // errors / isDirty / isValid / isSubmitting ...(Proxy。後述)
reset, // フォーム全体をリセット
resetField, // 単一フィールドだけリセット
setValue, // プログラムから値を設定
getValues, // 現在値を取得(購読しない=再描画しない)
watch, // 値を購読(呼んだ場所が再描画される)
setError, // サーバーエラー等を手動設定
setFocus, // 任意フィールドにフォーカス
trigger, // 手動でバリデーション実行
getFieldState,// 単一フィールドの状態を取得
} = useForm<Schema>({
resolver: zodResolver(schema),
defaultValues: { name: "", age: 0 }, // 重要:全フィールドの初期値を与える
mode: "onTouched", // 検証タイミング(下表)
reValidateMode: "onChange", // 一度エラーになった後の再検証タイミング(既定 onChange)
criteriaMode: "firstError", // "all" にすると1項目の全エラーを収集
shouldFocusError: true, // 送信失敗時に最初のエラー項目へ自動フォーカス(既定 true)
delayError: 0, // エラー表示を N ミリ秒遅延(連打時のチラつき抑制)
});
2-1. mode: Choosing the Validation Timing
mode | When it validates | Where to use it |
|---|---|---|
onSubmit (default) | On submit | General forms where you don't want to interrupt typing |
onBlur | When focus leaves | The natural UX of "point it out once they've finished typing" |
onTouched | First time on blur, after that on change | Balanced (the recommended landing point) |
onChange | On every input | Instant feedback (more re-renders — caution) |
all | Both blur and change | Strictest (max cost) |
The official docs, too, state for onChange that "a re-render runs on every input, which can heavily impact performance." The default of onTouched or onBlur is recommended. Note that reValidateMode is the re-validation timing "after an error has appeared once," separate from the initial-validation mode.
2-2. defaultValues, values, and errors
defaultValues… the initial values. They're cached and updated withreset. To outputisDirty/dirtyFieldscorrectly, give them to all fields (they're the baseline for the diff). Don't makeundefinedan initial value.values… reactive values. Use when you want to overwrite later with external data (API fetch). Whenvalueschanges, the internal equivalent ofresetruns and re-syncs.errors… the entry point for reactively reflecting errors passed from outside (the server). Usable for "declarative reflection of server-origin errors," paired withvalues.
// 編集フォーム:API から取れたら自動で反映される
const { data } = useFetchUser(id);
useForm({
defaultValues: { name: "", email: "" },
values: data, // 取得後に上書き(リアクティブ)
});
2-3. Reading defaultValues Asynchronously (Loading an Edit Form)
You can pass an async function to defaultValues too. It's the standard for an edit form that "makes server-fetched values the initial values." While loading, formState.isLoading is true, and once initialization completes, formState.isReady is true — this lets you write even skeleton display declaratively.
const {
register,
formState: { isLoading, isReady },
} = useForm<UserForm>({
// payload は不要なら省略可。Promise を返すだけ
defaultValues: async () => {
const res = await fetch(`/api/users/${id}`);
return (await res.json()) as UserForm; // 取得値がそのまま初期値に
},
resolver: zodResolver(userSchema),
});
if (isLoading) return <FormSkeleton />; // 取得中はスケルトン
// isReady を使えば「内部の初期化完了」までを厳密に待てる
valuesvs. asyncdefaultValues: if you're merely "pouring in" data the parent already fetched, usevalues. If you want the form itself to hold the "responsibility of fetching the initial values" and embed the loading UI, use asyncdefaultValues. From an SRP view, placing data fetching outside the form (a Server Component or TanStack Query) and passing it viavaluestends to be more loosely coupled.
3. register: Registering Native Inputs
register("name") returns { ref, name, onChange, onBlur }, spread onto the input. Nesting and arrays can be expressed with dot notation too.
<input {...register("name")} /> // { name: value }
<input {...register("address.city")} /> // { address: { city: value } }
<input {...register("items.0.title")} /> // { items: [{ title: value }] }
Main options:
valueAsNumber… convert the value tonumber(a must for numeric input)valueAsDate… convert toDatesetValueAs… an arbitrary conversion function (can't be combined withvalueAsNumberetc.)disabled… disable the input (the value becomesundefinedand is excluded from validation)deps… fields to re-validate in tandem when this field is validated (e.g., a confirmation password)onChange/onBlur… when you want your own handler to run in addition to RHF's registration
// 「パスワード」を変えたら「確認用」も再検証する
<input type="password" {...register("password", { deps: ["passwordConfirm"] })} />
Type-safety notes (official rules): arrays support dot notation only.
register("items.0.title")is ✅,register("items[0].title")is ❌. Also you can't clear options withregister("test", {})orregister("test", undefined)(state them explicitly, like{ required: false }).valueAs*runs before built-in validation, so write the Zod side on the premise of validating the post-conversion type (number, etc.).
4. formState Is a Proxy — The Biggest Pitfall
formState contains errors / isDirty / isValid / isValidating / isSubmitting / isSubmitSuccessful / isLoading / isReady / submitCount / touchedFields / dirtyFields / validatingFields / defaultValues / disabled. This is the most-mistaken point in RHF.
In the official words, formState is wrapped in a Proxy for rendering performance and "skips the update logic for state you're not subscribed to." That is, unless you read it out in advance and subscribe, that value won't update.
// ❌ formState.isValid を条件式の中で初めて参照している
// → Proxy が購読しないため、isValid の変化でボタンが更新されない
return <button disabled={!formState.isDirty || !formState.isValid}>送信</button>;
// ✅ レンダリング前に分割代入して「購読」する
const { isDirty, isValid } = formState;
return <button disabled={!isDirty || !isValid}>送信</button>;
Similarly, when monitoring with useEffect, put formState as a whole in the dependency array (formState.errors alone can fail to fire due to batched updates).
useEffect(() => {
if (formState.isSubmitSuccessful) reset();
}, [formState, reset]); // ✅ formState 全体を依存に
Get this "read first = subscribe" into your body and you avoid most of RHF's inscrutable "the state doesn't change" bugs. Note also that isValid's evaluation timing changes depending on mode (with onSubmit, it can be optimistically true before submission).
5. "Design" Re-Rendering: watch / useWatch / useFormState / getValues
The biggest reason to choose RHF is performance, yet misuse watch and re-rendering explodes. Whether you can design this is where skill shows. Let's nail down the character of the four reading methods.
| Method | Subscribes? | Where it re-renders | Where to use it |
|---|---|---|---|
getValues() | No | Nowhere | Just reading values on submit / inside event handlers |
watch("x") | Yes | The whole component that called useForm | Small scale. Handy but large blast radius |
useWatch({ name }) | Yes | Only the child that called this hook | Isolate value display in a small child |
useFormState({ control }) | Yes (formState only) | Only the child that called this hook | Spots that only need formState, like a submit button |
The key is "where you read = where it re-renders." So the standard is to carve only "the leaf you want to reflect the value" into a small component and call useWatch there. The parent (the whole form) doesn't re-render.
import { useForm, useWatch, type Control } from "react-hook-form";
// ✅ 合計表示だけを子に隔離。明細を打っても再描画されるのは <Total> のみ
function Total({ control }: { control: Control<InvoiceForm> }) {
const items = useWatch({ control, name: "items" });
const total = items.reduce((sum, i) => sum + (i.price ?? 0) * (i.qty ?? 0), 0);
return <output>{total.toLocaleString()} 円</output>;
}
function InvoiceForm() {
const { register, control, handleSubmit } = useForm<InvoiceForm>({
defaultValues: { items: [{ price: 0, qty: 1 }] },
});
return (
<form onSubmit={handleSubmit(save)}>
{/* ...明細入力... */}
<Total control={control} /> {/* ここだけ再描画される */}
</form>
);
}
Similarly, if you want only the submit button's enablement to look at isValid / isDirty, carve the button into a child and use useFormState, and the parent form doesn't re-render at all while typing.
import { useFormState } from "react-hook-form";
function SubmitButton({ control }: { control: Control<Schema> }) {
const { isDirty, isValid, isSubmitting } = useFormState({ control });
return (
<button disabled={!isDirty || !isValid || isSubmitting}>
{isSubmitting ? "送信中…" : "送信"}
</button>
);
}
Anti-pattern: calling
const values = watch();(no argument = subscribe to all fields) right underuseFormcauses the whole form to re-render on every character, halving the point of using RHF. Even when you "want to see the whole," the right move is to loweruseWatchto the displaying leaf.
6. Controller / useController: Integrating Controlled UI Libraries
Controlled components like shadcn/ui, MUI, Ant Design, or React-Select can't get their value via ref, so register doesn't connect to them. Here you use Controller (or useController).
import { useForm, Controller } from "react-hook-form";
import ReactDatePicker from "react-datepicker";
function App() {
const { control, handleSubmit } = useForm<{ publishedAt: Date }>();
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<Controller
control={control}
name="publishedAt"
render={({ field: { onChange, onBlur, value, ref }, fieldState }) => (
<>
<ReactDatePicker onChange={onChange} onBlur={onBlur} selected={value} ref={ref} />
{fieldState.error && <p role="alert">{fieldState.error.message}</p>}
</>
)}
/>
<button>送信</button>
</form>
);
}
The role of each property field provides (official):
| Property | Role |
|---|---|
onChange | Sends the value back to RHF |
onBlur | Notifies that the input was touched (focus/blur) |
value | The input's current value |
ref | Focuses on error |
name | The field name |
disabled | The whole-form / individual disabled state |
From fieldState you get invalid / isDirty / isTouched / error, and you can flow that field's standalone state straight into a11y attributes.
Anti-pattern: don't apply both
registerandController(field) to the same input (<input {...field} {...register('x')} />is ❌). Also, in a controlled input,onChange(undefined)is invalid; usenullor an empty string. To make a reusable input component, theuseControllerhook is handy (next section).
7. FormProvider × useController: Making Type-Safe "Reusable Fields"
A real-world form is "dozens of identical-looking fields lined up." Hard-write register, label, aria-*, and error display on each field and both DRY and a11y collapse. The right move is to distribute the form context with FormProvider and componentize a single field with useController. Keep types safe with FieldValues and Path<T>.
"use client";
import {
useController,
useFormContext,
type FieldValues,
type Path,
} from "react-hook-form";
type TextFieldProps<T extends FieldValues> = {
name: Path<T>; // ✅ そのスキーマに存在するキーしか渡せない(型安全)
label: string;
type?: React.HTMLInputTypeAttribute;
};
// SRP:このコンポーネントの責務は「1つの入力+ラベル+エラー+a11y」だけ
export function TextField<T extends FieldValues>({
name,
label,
type = "text",
}: TextFieldProps<T>) {
const { control } = useFormContext<T>();
const { field, fieldState } = useController<T>({ name, control });
const errorId = `${name}-error`;
return (
<div className="field">
<label htmlFor={name}>{label}</label>
<input
id={name}
type={type}
aria-invalid={fieldState.invalid}
aria-describedby={fieldState.error ? errorId : undefined}
{...field}
/>
{fieldState.error && (
<p id={errorId} role="alert">
{fieldState.error.message}
</p>
)}
</div>
);
}
The usage side is just this. Since FormProvider distributes methods, you don't have to keep passing control as a prop to child fields (prop-drilling solved).
"use client";
import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
export function SignupForm() {
const methods = useForm<Schema>({ resolver: zodResolver(schema) });
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onValid)} noValidate>
<TextField<Schema> name="email" label="メールアドレス" type="email" />
<TextField<Schema> name="name" label="氏名" />
<SubmitButton control={methods.control} />
</form>
</FormProvider>
);
}
The problems this design solves at once:
- DRY … consolidate the logic of a11y attributes, error display, and id numbering in one place.
- ETC (ease of change) … swapping the design system is only inside
TextField. - Performance …
useControllerre-renders only on that field's change, so an input doesn't drag in other fields (§5's isolation works at the component level). - Type safety … since
nameisPath<T>, a nonexistent key or a typo is a compile error.
8. useFieldArray: Dynamic Fields (Line Items, Tags, Multiple Contacts)
"Add invoice line items by row," "a variable number of tags" — dynamically adding/removing array items is useFieldArray's turn.
import { useForm, useFieldArray } from "react-hook-form";
function InvoiceForm() {
const { register, control, handleSubmit } = useForm<{
items: { name: string; price: number }[];
}>({ defaultValues: { items: [{ name: "", price: 0 }] } });
const { fields, append, remove } = useFieldArray({ control, name: "items" });
return (
<form onSubmit={handleSubmit((d) => console.log(d))}>
{fields.map((field, index) => (
// ✅ key は必ず field.id(index は不可)
<div key={field.id}>
<input {...register(`items.${index}.name`)} />
<input type="number" {...register(`items.${index}.price`, { valueAsNumber: true })} />
<button type="button" onClick={() => remove(index)}>削除</button>
</div>
))}
<button type="button" onClick={() => append({ name: "", price: 0 })}>明細を追加</button>
<button>送信</button>
</form>
);
}
The points the official docs emphasize:
- Always use
field.idforkey(indexis a cause of fields breaking on re-render). - The value passed to
append/prepend/insertmust be the complete shape, not partial (append({})is ❌). - Don't use multiple
useFieldArraywith the samename.shouldUnregister: trueis unsupported inuseFieldArray.
The operation APIs are complete: append / prepend / insert / remove / move / swap / update / replace. To display a line-item total in real time, per §5, lower useWatch to a leaf <Total> and isolate the re-rendering.
9. handleSubmit: Success, Failure, Server Errors, and "Double Submission" Defense
handleSubmit(onValid, onInvalid?) calls onValid(data) on validation pass and onInvalid(errors) on validation failure. onValid can be async.
const {
handleSubmit,
setError,
formState: { isSubmitting, errors },
} = useForm<Schema>({ resolver: zodResolver(schema) });
const onValid = async (data: Schema) => {
try {
await api.submit(data);
} catch (e) {
// 重要:handleSubmit は onValid 内の例外を握りつぶさない。必ず自分で捕捉する
// サーバーエラーはフォーム全体エラーとして root に積む
setError("root.server", {
message: "送信に失敗しました。時間をおいて再試行してください。",
});
}
};
return (
<form onSubmit={handleSubmit(onValid, (errs) => console.log(errs))} noValidate>
{/* ...入力... */}
{errors.root?.server && <p role="alert">{errors.root.server.message}</p>}
{/* ✅ 送信中はボタンを無効化=二重送信を構造的に防ぐ(冪等性の入口) */}
<button disabled={isSubmitting}>{isSubmitting ? "送信中…" : "送信"}</button>
</form>
);
Official note: handleSubmit does not swallow errors inside onValid, so try/catch the API call yourself and register the server-side error with setError (this also keeps formState.isSubmitSuccessful false).
From a reliability view, enforce two things:
- Double-submission prevention … stop the button while submitting with
disabled={isSubmitting}. Seal repeated clicks during network latency in the UI, and reject double execution on the server too with an idempotency key (essential for payments. See the idempotency design). - Per-field server errors … return field-specific errors like "this email is already in use" to the field with
setError("email", { message }), and focus there withshouldFocus.
For cases where you want to update server state (a list, etc.) after submission success, connecting the form submit to a TanStack Query Mutation is the standard. For the design of optimistic updates and cache invalidation, see the TanStack Query v5 Practical Guide.
10. Input Type ≠ Output Type: Type-Safe .transform() with the 3rd Generic
In Zod 4, .transform() and z.coerce make input and output types diverge (see the Zod 4 Practical Guide). RHF catches this with the 3rd generic. The 3-argument useForm<TFieldValues, TContext, TTransformedValues> expresses exactly this "before/after conversion."
import * as z from "zod";
const schema = z.object({
// フォーム上は文字列、送信後は number にしたい
price: z.string().transform((s) => Number.parseInt(s, 10)),
});
type FormInput = z.input<typeof schema>; // { price: string } ← register が扱う型
type FormOutput = z.output<typeof schema>; // { price: number } ← handleSubmit が受け取る型
const { register, handleSubmit } = useForm<FormInput, unknown, FormOutput>({
resolver: zodResolver(schema),
});
handleSubmit((data) => {
data.price; // number として型がつく(変換後)
});
- 1st generic … the input type (the pre-conversion form value that
register/watchhandle) - 3rd generic … the output type (the post-conversion finalized value that
handleSubmitreceives)
This separation realizes "the screen is a string, the logic is a number" with no type lie. Note that zodResolver can adjust behavior with its 2nd argument: zodResolver(schema, undefined, { raw: true }) returns the pre-conversion raw value (when you want to hold the raw value in the form and convert separately after submission). Other libraries like Valibot / ArkType can enjoy the same type separation via standardSchemaResolver.
11. Accessibility (a11y): The Trio You Must Implement
Form a11y isn't only "rejecting" but "conveying what to fix and how to assistive tech too." The official example uses aria-invalid and role="alert". In practice, the ideal is to associate the error text to the input with aria-describedby (the TextField in §7 has this built in).
const { register, formState: { errors } } = useForm<Schema>({ resolver: zodResolver(schema) });
<label htmlFor="email">メールアドレス</label>
<input
id="email"
type="email"
{...register("email")}
aria-invalid={errors.email ? "true" : "false"}
aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && (
<p id="email-error" role="alert">
{errors.email.message}
</p>
)}
The trio:
aria-invalid… tells assistive tech the error state.aria-describedby… ties the error text (withid) to the input, having it read aloud on focus.role="alert"… notifies the error's appearance immediately.
In addition, shouldFocusError (default true) auto-focuses the first error field on submission failure. Put noValidate on the form and unify the validation UI on RHF/Zod for consistent messages. If you want to systematize a11y including WCAG 2.2, see the React/Next.js Accessibility Implementation Guide.
12. Next.js App Router: "use client" / Server Actions / <Form> / Progressive
12-1. The Iron Rule of Placement
useForm / register / Controller are React hooks, so always put them in a "use client" client component (don't write them right under page.tsx). The page (a Server Component) concentrates on data fetching and layout, and separates the form body into a child client component.
12-2. The "Double Validation" with Server Actions Is the Production Pattern
The iron rule when combining with Server Actions is — do instant-UX validation with zodResolver on the client while re-validating with the same Zod schema on the server (don't trust the client). Share one schema and apply it at both ends — that's both safe and DRY.
// schema.ts —— クライアント/サーバー共有の単一スキーマ
export const contactSchema = z.object({
email: z.email({ error: "メールアドレスを正しく入力してください" }),
message: z.string().min(10, { error: "10文字以上で入力してください" }),
});
export type Contact = z.infer<typeof contactSchema>;
// actions.ts —— サーバーでも必ず再検証する
"use server";
import * as z from "zod";
import { contactSchema } from "./schema";
export async function submitContact(input: unknown) {
const parsed = contactSchema.safeParse(input); // 信頼境界はここ
if (!parsed.success) {
// フィールド別エラーを構造化して返す
return { ok: false as const, errors: z.flattenError(parsed.error).fieldErrors };
}
await db.contacts.insert(parsed.data);
return { ok: true as const };
}
// contact-form.tsx —— クライアントは即時UX、サーバー結果を setError に反映
"use client";
const onValid = async (data: Contact) => {
const res = await submitContact(data);
if (!res.ok) {
// サーバーが返した項目別エラーを RHF に流し込む(信頼の源はサーバー)
for (const [name, messages] of Object.entries(res.errors)) {
if (messages?.[0]) setError(name as keyof Contact, { message: messages[0] });
}
}
};
The conventions of server-side validation and shaping are detailed in the Zod 4 Practical Guide.
12-3. RHF's <Form> Component and Progressive Enhancement
RHF v7 has a <Form> component that takes over submission. Specify action and it handles validation → POST via fetch, calling onSuccess / onError per the result. Omit action and it becomes a native submission, the foundation that works even with JS disabled.
import { useForm, Form } from "react-hook-form";
function Newsletter() {
const { control, register, formState: { errors } } = useForm({
resolver: zodResolver(contactSchema),
});
return (
<Form
action="/api/contact" // 指定で fetch POST/省略でネイティブ送信
method="post"
control={control}
onSuccess={() => {/* 2xx */}}
onError={() => setError("root.server", { message: "送信に失敗しました" })}
>
<label htmlFor="email">メール</label>
<input id="email" {...register("email")} aria-invalid={!!errors.email} />
<button>登録</button>
</Form>
);
}
Further, using useForm({ progressive: true }) + shouldUseNativeValidation: true outputs native validation attributes like required / min, so the browser's standard validation works even before JS arrives. It's an option that pairs well with Next.js's server-centric design aiming for forms "not broken from the first render."
13. Testability: Unit-Testing a Form
Since RHF + Zod consolidates validation into one schema, the test falls naturally into "render → operate → verify display/submission." With @testing-library/react + @testing-library/user-event, operating via roles/labels verifies a11y at the same time (getByLabelText passing = proof that the label is correctly tied).
// contact-form.test.tsx (jsdom 環境)
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { ContactForm } from "./contact-form";
describe("ContactForm", () => {
it("空送信は onSubmit を呼ばず、エラーを読み上げ領域に出す", async () => {
const onSubmit = vi.fn();
render(<ContactForm onSubmit={onSubmit} />);
await userEvent.click(screen.getByRole("button", { name: "送信" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"メールアドレスを正しく入力してください",
);
expect(onSubmit).not.toHaveBeenCalled();
});
it("妥当な入力で整形済みデータを onSubmit に渡す", async () => {
const onSubmit = vi.fn();
render(<ContactForm onSubmit={onSubmit} />);
await userEvent.type(screen.getByLabelText("メールアドレス"), "a@example.com");
await userEvent.type(screen.getByLabelText("お問い合わせ"), "詳しい資料が欲しいです");
await userEvent.click(screen.getByRole("button", { name: "送信" }));
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith({
email: "a@example.com",
message: "詳しい資料が欲しいです",
}),
);
});
});
The point is "don't test the implementation's internal state." Rather than reading formState.isValid directly, verify what the user sees (the error message, the firing of submission). This makes tests resistant to breakage even if you change RHF internals or mode (refactor resilience). For a design including E2E, see the Playwright E2E Practical Guide.
14. A Quick Reference of Other APIs That Matter in Practice
| API | What it does | Typical use |
|---|---|---|
reset(values?, options?) | Initialize the whole form / re-set with given values | After submission success, on edit cancel. Control the retained range with keepDirtyValues etc. |
resetField(name) | Initialize a single field only | "Revert just this item" |
setFocus(name) | Focus an arbitrary field | Step navigation, error guidance |
getFieldState(name) | Get a single field's state | Individual invalid / isDirty judgment |
trigger(name?) | Fire validation manually | Validate only that step on a wizard's "Next" |
unregister(name) | Unregister | Cleanup of conditionally-displayed fields |
disabled (useForm) | Disable the whole form | Lock all inputs while submitting |
createFormControl() | Generate form control outside a component | Read state independent of rendering, cross-step wizards |
createFormControl is a bit advanced. It extracts useForm's internals to the outside; pass the generated formControl to useForm({ formControl }) and you can read/write form state independent of component re-rendering. It's effective for giant multi-step wizards spanning multiple steps, or cases where you want to monitor state from logic outside the form. First consider whether FormProvider suffices, and use this only when truly needed (YAGNI).
15. Best Practices & Anti-Patterns
| Do | Don't |
|---|---|
Delegate validation to Zod with zodResolver (unify type, validation, wording) | Mix RHF built-in rules with the resolver |
Destructure and read formState first to subscribe | Reference formState.isValid for the first time inside a conditional |
Give defaultValues for all fields | Only some / undefined initial values, breaking isDirty |
Display values with useWatch at the leaf, buttons with useFormState | watch() with no argument right under useForm, re-rendering the whole thing |
Controlled UI with Controller/useController, native with register | Try to connect a controlled component with register |
Componentize reusable fields type-safely with FormProvider + Path<T> | Hard-write register, aria-* on each field, duplicated |
key of useFieldArray is field.id | key={index}, breaking fields |
Separate input/output types of .transform() with the 3rd generic | Swallow the post-conversion type with as |
aria-invalid + aria-describedby + role="alert" | Rely on visuals alone for error text |
While submitting, seal double submission with disabled/isSubmitting | Repeated clicks fire multiple requests |
Server errors with setError, try/catch the API yourself | Misunderstand that handleSubmit handles errors for you |
| Share the same Zod schema on client/server | Client validation only, passing through the server |
16. FAQ
Q. Why React Hook Form over writing useState directly?
A. RHF is uncontrolled-first and almost eliminates re-rendering while typing. It's advantageous in all of perceived speed on large forms, code conciseness, and validation integration. Conversely, with a few fields, plain form + Server Action can be enough (§1-2).
Q. How do I choose between register and Controller?
A. Native HTML inputs (input / select / textarea) are register; controlled UI libraries whose value you can't get via ref (shadcn/MUI/React-Select, etc.) are Controller/useController.
Q. Toggling the button's enablement with isValid doesn't work.
A. Because formState is a Proxy. Destructure and subscribe before rendering, like const { isValid } = formState;. Reference it for the first time inside a conditional and it won't update (§4).
Q. Input is heavy — the whole form re-renders on every character.
A. Check whether you're calling watch() right under the form. Isolate display to a leaf useWatch, the button to useFormState, and componentize reusable fields with useController, and re-rendering is confined to that field (§5, §7).
Q. I want to put initial values from an API into an edit form.
A. If the parent already fetched, pour them in with values; if the form itself should hold the fetch responsibility, use async defaultValues + isLoading/isReady for skeleton display (§2-3).
Q. useForm can't be used in Server Components.
A. Since it's a hook, "use client" is required. Split server processing into a Server Action / route handler, and re-validate with the same Zod schema (§12).
Q. Why choose Zod over Yup or valibot?
A. Zod is TypeScript-first with the most natural type inference, and a thick ecosystem (@hookform/resolvers, drizzle-zod, AI SDK's structured output, etc.). If you want valibot/ArkType, connect them similarly with standardSchemaResolver. For details, see the Zod 4 Practical Guide.
Q. Numbers and dates aren't extracted well.
A. Specify register("age", { valueAsNumber: true }) / { valueAsDate: true }. Without it they're passed as strings (§3).
Summary: A Form Is the Confluence of "State × Validation × Accessibility"
The key to mastering React Hook Form is capturing a form not as "a collection of input fields" but as "a design target where state, validation, and accessibility converge." Looking back at this article's pillars —
- With uncontrolled × re-render minimization, build a snappy large form.
- Delegate validation to Zod with
zodResolver, consolidating type, validation, and wording into a single schema. - Understand
formState's Proxy subscription and enforce "read first." - With
watch/useWatch/useFormState, confine re-rendering to "where you read," and make type-safe reusable fields withFormProvider+useController. - Handle controlled UI and dynamic fields with
Controller/useFieldArray, and finish at production quality with the a11y trio, double-submission prevention, and client/server double validation.
These aren't shallow techniques but "design choices" that simultaneously raise user experience, type safety, maintainability, and performance. Apply them correctly and both the inputter's experience (accurate pointers) and the developer's experience (resistance to breakage) are lifted.
If you need complex form design for a business system, a11y/type-safety of an existing form, or performance improvement, feel free to reach out. The case study below introduces the process of designing and building a B2B SaaS that underpins an industry's core operations, prioritizing type safety, maintainability, and usability.