Skip to content
Chapter 45Lesson 2

The five primitives: useForm, register, Controller, handleSubmit, formState

React Hook Form's whole surface as five primitives mapped to three concerns, rebuilding the invoice form end to end.

Last lesson you decided when to reach for React Hook Form, and you saw the submit change hands: the action prop dropped, and the form began calling createInvoice from its own handler. What you didn’t see is how RHF holds the form together once it’s in charge. This lesson reads RHF’s whole surface as five primitives across three concerns, then rebuilds last chapter’s invoice create form in RHF, end to end. The resolver is the one piece left for next lesson, a single line marked “wired next lesson”; the action still parses on entry, so the trust boundary doesn’t move.

Five hooks as a flat list is five things to memorize. A better frame: a form has exactly three concerns, and each primitive answers one.

  1. Get values in and track them. Something owns each field’s live value and keeps RHF aware of it.
  2. Submit. Something intercepts the submit, runs validation, and hands off the result.
  3. Read the state back out. Something exposes the errors, the “is it submitting” flag, and the live values so the UI can react.

Every primitive you’re about to meet slots into one of these three. Read the map below across, concern by concern.

1 Get values in & track
register native inputs
Controller / useController UI-library inputs
2 Submit
handleSubmit intercept · validate · hand off
3 Read state out
formState errors, submitting, dirty
watch / useWatch live values
the container useForm called once — produces every primitive above

useForm is the root: called once, it returns every other primitive in the map.

The relationship that matters most: you call useForm once, and everything else is a property of what it returns. You don’t import register, control, handleSubmit, formState, watch, or reset separately; you reach into the form object useForm hands back. Get that right and the rest is learning what each property does.

useForm is the single call at the top of your form component. It creates the form’s state container: one object holding every field’s value, error, and flag. Because it’s a hook, the form is a Client Component ('use client' at the top of the file), as it already was once you used useActionState last chapter.

Here is the call, one option at a time.

const form = useForm<InvoiceInput>({
resolver: zodResolver(InvoiceSchema),
defaultValues: { customer: '', email: '', total: 0 },
mode: 'onBlur',
});

The generic tells RHF the shape of your fields, the same InvoiceInput type from last chapter. Everything downstream (register('email'), the values handed to onSubmit) is typed from it.

const form = useForm<InvoiceInput>({
resolver: zodResolver(InvoiceSchema),
defaultValues: { customer: '', email: '', total: 0 },
mode: 'onBlur',
});

The resolver is the function RHF calls to validate. zodResolver builds one from your Zod schema, so the form validates against the same schema the action parses with. You’ll wire it fully next lesson; for now, know it points validation at the schema.

const form = useForm<InvoiceInput>({
resolver: zodResolver(InvoiceSchema),
defaultValues: { customer: '', email: '', total: 0 },
mode: 'onBlur',
});

defaultValues does double duty: it sets each field’s initial value and declares the full set of fields RHF tracks. On a create form, every field starts empty ('', 0). In practice this line is not optional, for the reason below.

const form = useForm<InvoiceInput>({
resolver: zodResolver(InvoiceSchema),
defaultValues: { customer: '', email: '', total: 0 },
mode: 'onBlur',
});

mode decides when validation first runs for a field, the trigger from last lesson. 'onBlur' validates when the user leaves a field, the course default past the native pattern.

const form = useForm<InvoiceInput>({
resolver: zodResolver(InvoiceSchema),
defaultValues: { customer: '', email: '', total: 0 },
mode: 'onBlur',
});

The returned form object is the container. It carries register, control, handleSubmit, formState, watch, setValue, reset, and more; every other primitive in this lesson is a property of it.

1 / 1

Two options trip up beginners.

defaultValues is non-optional in practice. Skipping it on a create form is tempting, since every field starts empty anyway. A field with no default renders as uncontrolled , then flips to controlled the instant the user types and RHF starts tracking it. React warns on that flip, so you’ve shipped a subtle bug. Declaring defaultValues for every field makes each one controlled from the first render. An edit form uses the same line to carry the row’s current values instead of empty strings.

mode is the validation-timing dial. Its five settings differ in when the first error appears for a field:

mode: 'onSubmit' | 'onBlur' | 'onChange' | 'onTouched' | 'all';

Reach for 'onBlur': it gives the “fill the field, leave it, see the error” feel without re-validating on every keystroke. A sibling option, reValidateMode, controls when validation re-runs after a field already has an error; you’ll meet it next lesson alongside the resolver.

register wires a native input, and it’s the default you should reach for. It’s one spread:

<input {...form.register('email')} />

The spread puts four things on the input: a name, a ref, an onChange, and an onBlur. The ref is the one that matters. Through it the DOM owns the live value, like the uncontrolled inputs from last chapter: RHF keeps no copy of what the user typed and reads the value off the DOM when it needs it, on submit and at whatever validation moment your mode picked.

That’s why register is the fast path. The value lives in the DOM, not React state, so typing into a registered input re-renders nothing.

The rule is simple: reach for register on every native input, text, email, password, number, textarea, checkbox, radio, or select. The controlled path (next section) is only for inputs that can’t take a register spread.

One footgun produces a form that silently stops working. If you add your own onChange to a registered input, the order you spread matters.

<input
{...form.register('total')}
onChange={(e) => setPreview(e.target.value)}
/>

Broken. Your onChange comes after the spread, so it overwrites the one register put there. RHF stops seeing the field change: the value never updates, validation never fires.

That’s enough for a complete, working RHF form, before Controller, watch, or the design-system layer: useForm at the top, two registered inputs, handleSubmit on the form, errors from formState, and a submit button that disables while the request is in flight.

'use client';
export const NewInvoiceForm = () => {
const form = useForm<InvoiceInput>({
defaultValues: { customer: '', email: '', total: 0 },
mode: 'onBlur',
});
const onSubmit = async (values: InvoiceInput) => {
await createInvoice(values);
};
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<input {...form.register('customer')} placeholder="Customer" />
<input {...form.register('email')} type="email" placeholder="Email" />
{form.formState.errors.email != null && (
<p>{form.formState.errors.email.message}</p>
)}
<button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving…' : 'Create invoice'}
</button>
</form>
);
};

The resolver is omitted on purpose: without it there’s no validation yet, fine for a first look. It arrives next lesson, and the error rendering is already wired for it. Everything past this point builds on this shape.

Now it’s your turn. The starter calls useForm with defaultValues set; you wire the two inputs and the submit.

Fill in the three primitives that wire this form: two field registrations and the submit interceptor. `useForm` and `defaultValues` are already in place — you connect the inputs and the submit. Pick the right option from each dropdown, then press Check.

'use client';
export const NewInvoiceForm = () => {
const form = useForm<InvoiceInput>({
defaultValues: { customer: '', total: 0 },
mode: 'onBlur',
});
const onSubmit = async (values: InvoiceInput) => {
await createInvoice(values);
};
return (
<form onSubmit={form.___(onSubmit)}>
<input {...form.___('customer')} placeholder="Customer" />
<input {...form.___('total')} type="number" placeholder="Total" />
<button type="submit" disabled={form.formState.isSubmitting}>
Create invoice
</button>
</form>
);
};

Controller and useController: wire controlled inputs

Section titled “Controller and useController: wire controlled inputs”

register needs a native input with a ref. Many form inputs, shadcn’s Combobox, a Radix Select, a date picker, render no native <input name> at all. They own their value through value/onChange props, the controlled pattern from last lesson, so register can’t reach them. Controller fills that gap.

Controller is a render-prop bridge. Give it a field name and the form’s control, and it hands back the wiring for the controlled component:

<Controller
name="role"
control={form.control}
render={({ field, fieldState }) => (
<Select
value={field.value}
onValueChange={field.onChange}
onBlur={field.onBlur}
>
{/* options */}
</Select>
)}
/>

The render prop hands you two objects. field is { value, onChange, onBlur, name, ref }: feed field.value into the component’s value and its change callback into field.onChange, and RHF owns the input just as it owns a registered one. fieldState is this field’s state, { invalid, error, isDirty, isTouched }: it’s how a field renders itself as invalid, and the design-system layer leans on it heavily. The control you pass in comes off the form object from useForm.

The same bridge has a hook form: useController({ name, control }) returns the identical field and fieldState. The choice is ergonomic. Use Controller for a one-off integration in the form’s JSX; use useController inside a reusable field component that owns its UI, say a <DatePickerField> that calls useController internally so callers pass only name and control.

The trap is overreach: wrapping a plain <input> that register would cover makes the field controlled and adds re-renders for nothing. Keep the line sharp:

handleSubmit: intercept, validate, hand off

Section titled “handleSubmit: intercept, validate, hand off”

Last lesson the action prop dropped and form.handleSubmit(onSubmit) took its place on the <form>.

handleSubmit returns a DOM submit handler that intercepts, validates, then branches, all before any of your code sees the data.

intercept Submit clicked — handleSubmit intercepts the native POST
user Submit
preventDefault()
RHF Validate
valid
your code onSubmit(values)
invalid
RHF formState.errors

The submit is caught in JS — preventDefault already fired, so no request has left the page.

The user clicks submit. The browser would normally POST the form — but handleSubmit intercepted it (it called preventDefault for you), so nothing leaves the page yet.
validate RHF runs the resolver — the same Zod schema the action parses with
user Submit
intercepted
RHF Validate
valid
your code onSubmit(values)
invalid
RHF formState.errors

Before your code runs, RHF validates the values against the resolver — the client-side mirror of the action's safeParse.

RHF runs client-side validation against the resolver — the same Zod schema the action uses. This is the check that drives the inline error UX.
branch Validation done — RHF picks a lane on the result
user Submit
intercepted
RHF Validate
valid
your code onSubmit(values)
invalid
RHF formState.errors

One check, two exits: exactly one lane is taken — RHF decides which of your callbacks gets to run.

The flow branches on the result. Valid one way, invalid the other — RHF picks which of your callbacks runs.
valid path onSubmit(values) runs with the typed InvoiceInput
user Submit
intercepted
RHF Validate
valid
your code onSubmit(values)
invalid
RHF formState.errors

Valid: onSubmit receives clean, typed values — your code runs and calls createInvoice.

On valid: RHF calls onSubmit(values) with the typed values — already parsed and shaped to InvoiceInput. This is where your code finally runs and calls the Server Action.
invalid path onSubmit is skipped — formState.errors is populated instead
user Submit
intercepted
RHF Validate
valid
skipped onSubmit(values)
invalid
RHF formState.errors

Invalid: onSubmit never runs; RHF fills formState.errors so each field shows its message.

On invalid: RHF skips onSubmit entirely and populates formState.errors instead, so each field can render its message. An optional second argument, handleSubmit(onSubmit, onInvalid), lets you also run code on the invalid branch.

On the page it is just <form onSubmit={form.handleSubmit(onSubmit)}>. Validation has already run by the time onSubmit is called, so the values it receives are clean and typed, and you call the action directly:

const onSubmit = async (values: InvoiceInput) => {
const result = await createInvoice(values);
// map result.error.fieldErrors back into the form — next lesson
};

That createInvoice is the unchanged Server Action from the last chapter. RHF validated those values for the user, but createInvoice still runs its own safeParse on entry: the client check is a convenience, the server check is the gate.

One seam changed. Last chapter the submit button read its pending state with useFormStatus().pending, because the submit flowed through the action prop. With RHF it goes through handleSubmit, so the pending read is now form.formState.isSubmitting:

<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving…' : 'Create invoice'}
</Button>

The read moved because a different thing owns the submit now. Don’t reuse the last chapter’s useFormStatus-based <SubmitButton> here: it belongs to the native action-prop form.

formState is the read side of the form, what you reach into to render errors, disable buttons, and guard against losing unsaved work. Its members:

  • errors holds the per-field errors, keyed by field name, what each field renders next to itself.
  • isSubmitting is true while onSubmit is in flight; it drives the submit button’s disabled.
  • isDirty is true once the user changes any field from its default; it backs the “you have unsaved changes” guard before navigating away.
  • isValid, isSubmitSuccessful, touchedFields, and dirtyFields are situational; the three above cover almost every form.

formState also drives RHF’s performance. It’s a proxy : read a property and RHF re-renders your component only when that property changes. So destructure only what you use (const { errors, isSubmitting } = form.formState); read the whole object, or fields you don’t render, and you pay for re-renders you don’t care about.

This settles a common worry, that a form re-renders on every keystroke. With registered inputs, it doesn’t:

Why a registered input doesn't re-render the form

On the register tab, typing in one field re-renders that field alone: the value lives in the DOM, no React state changes. On the watch in the root tab, the same keystroke re-renders the whole form, because reading a live value in the root subscribes it to every change. The difference is where the value is read.

So the rule: keep watch and formState reads out of the form root. Push the read down into the child that needs the value, which is what the next primitive is for.

watch and useWatch: subscribing to live values

Section titled “watch and useWatch: subscribing to live values”

Sometimes you need a field’s value as the user types: a character counter, a “show the VAT field when country is EU” conditional, a running total. This is RHF’s controlled read side, and it comes in two shapes.

watch('email') returns a field’s live value and re-renders the calling component on every change. Call it in the form root and every keystroke re-renders the whole form, as the diagram showed. So reach for useWatch({ control, name }) instead: same live value, but called inside a small child, so only that child re-renders.

const CharCount = ({ control }: { control: Control<InvoiceInput> }) => {
const note = useWatch({ control, name: 'note' });
return <span>{note?.length ?? 0} / 280</span>;
};

You pass control down as a prop and the child subscribes itself. CharCount re-renders on every keystroke in note; the form root, the other fields, and the submit button stay stable.

The lever here is subscription placement, not useMemo. The course runs with the React Compiler on, so memoization is handled for you; scope in RHF is about where the subscription lives, not how the tree is memoized. Put useWatch in a leaf and the subscription stays small. Scope the subscription, don’t memoize the tree.

defaultValues and reset: prefill and clean state

Section titled “defaultValues and reset: prefill and clean state”

You met defaultValues under useForm as the field set RHF tracks. Its partner is reset: together they set the form’s starting values and return it to a clean state.

defaultValues has two uses. A create form uses empty defaults, so every field is known from the first render. An edit form uses the row’s current values, fetched by the Server Component and passed as a prop to the Client Component form, where defaultValues reads from it.

export const EditInvoiceForm = ({ invoice }: { invoice: Invoice }) => {
const form = useForm<InvoiceInput>({
defaultValues: {
customer: invoice.customer,
email: invoice.email,
total: invoice.total,
},
mode: 'onBlur',
});
// ...
};

form.reset(newValues?) re-sets the field values and clears the dirty and touched state. The canonical move is calling reset(savedValues) after a successful save, so the form stays open showing exactly what was saved, with no lingering “unsaved changes.”

reset has a timing trap. Call reset() inside onSubmit before the await resolves and you wipe the form before the action runs: the values are gone, and the request fires against whatever’s left. Call reset after the await, once the save has succeeded.

const onSubmit = async (values: InvoiceInput) => {
form.reset();
await createInvoice(values);
};

Wrong. reset() runs immediately, clearing the form before createInvoice starts. The action still receives values, since you captured them, but the user watches their form blank out mid-save.

The shadcn layout layer: Field + Controller

Section titled “The shadcn layout layer: Field + Controller”

Every form in this course uses shadcn for the visual row each field sits in: label, control, description, and error message, spaced consistently. The React chapters used shadcn’s older <Form> / <FormField> wrappers, which were bound to React Hook Form. Shadcn now leads with a form-library-agnostic Field family (Field, FieldLabel, FieldDescription, FieldError, FieldGroup, FieldSet) used directly with RHF’s Controller, so the same layout primitives work whether the form underneath is RHF, TanStack Form, or a native action. The old <Form> wrapper still works but is no longer the recommended start, so this course uses Field + Controller.

Here is the canonical field, showing how Controller feeds the Field row:

<Controller
name="email"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Send invoice to</FieldLabel>
<Input
{...field}
id={field.name}
type="email"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>

Controller wires this field into RHF: name keys it to the schema, control connects it to the form instance. Everything inside render is your layout: Field, FieldLabel, Input, and FieldError are the shadcn primitives.

<Controller
name="email"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Send invoice to</FieldLabel>
<Input
{...field}
id={field.name}
type="email"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>

Spreading field onto the <Input> hands it value, onChange, onBlur, and ref in one shot, the bundle you wired by hand earlier.

<Controller
name="email"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Send invoice to</FieldLabel>
<Input
{...field}
id={field.name}
type="email"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>

fieldState drives error presentation: data-invalid lets the Field style itself, aria-invalid tells assistive tech, and <FieldError> renders the message from an errors array.

<Controller
name="email"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Send invoice to</FieldLabel>
<Input
{...field}
id={field.name}
type="email"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>

FieldLabel’s htmlFor matches the input’s id, both from field.name, so clicking the label focuses the input and screen readers announce them together: the accessibility floor.

1 / 1

fieldState ({ invalid, error, isDirty, isTouched }) makes the row reactive to its own validity, which is why Controller is the shadcn-documented path and why reading the state straight from the render prop keeps the error wiring local. To group and space multiple fields, FieldGroup, FieldSet, and FieldLegend are the wrappers; for now just recognize them.

The piece to remember is fieldState : one field’s slice of state, handed to you exactly where you render that field.

Assemble everything into one shape, the artifact to reproduce from memory, since every later lesson varies it.

app/invoices/new-invoice-form.tsx
'use client';
export const NewInvoiceForm = () => {
const form = useForm<InvoiceInput>({
resolver: zodResolver(InvoiceSchema), // wired next lesson
defaultValues: { customer: '', email: '', total: 0 },
mode: 'onBlur',
});
const onSubmit = async (values: InvoiceInput) => {
const result = await createInvoice(values);
// map result.error.fieldErrors back into the form — next lesson
};
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* a Controller + Field per input */}
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving…' : 'Create invoice'}
</Button>
</form>
);
};

Read it back through the three concerns and the five primitives fall into place:

  • useForm creates the container (concern one) and produces every other primitive.
  • Controller and register get values in (concern one): Controller for UI-library fields, register for plain native inputs.
  • handleSubmit owns the submit (concern two): intercept, validate against the resolver, hand the typed values to onSubmit.
  • formState reads state back out (concern three): isSubmitting drives the button, errors drives each field.
  • watch / useWatch also read state out (concern three), in the leaves that need a live value.

Some pieces are left out on purpose. The resolver is present but unexplained: its wiring, the z.input versus z.output typing, and the round-trip that maps the action’s returned errors back into the form are all next lesson. The dynamic line-item array comes the lesson after, the multi-step wizard after that, both extending this skeleton.

The call you make on every field: register or Controller?

You’re adding a field for selecting a customer: a shadcn Combobox that holds its own selected value through value and onValueChange, with no native <input name> inside it. Which primitive wires it into the form?

Controller, because the combobox owns its value in React state — there’s no native input with a ref for register to attach to.
register, because every input in an RHF form is registered the same way.
Controller for every field, since it’s the more capable primitive and register is only for legacy forms.
Neither — a combobox can’t participate in form state and has to be tracked with separate useState.

These are references for the primitives you’ll lean on most. The next lesson wires the resolver, so these are for filling in the corners, not required reading.