The five primitives: useForm, register, Controller, handleSubmit, formState
The five React Hook Form primitives that hold a form together, mapped to the three concerns of getting values in, submitting, and reading state back out.
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 started calling createInvoice from inside its own handler. What you didn’t see is how RHF actually holds the form together once it’s in charge. That’s this lesson. By the end you’ll be able to read RHF’s entire surface as five primitives mapped to three concerns, and write the form skeleton every later lesson in this chapter builds on: the invoice create form from the last chapter, re-expressed in RHF and working end to end. One piece is left for next lesson, the resolver. Here it shows up as a single line marked “wired next,” and the focus stays on the primitives and the call shape. The trust boundary doesn’t move either, since the action still parses on entry, exactly as before.
A form has three concerns
Section titled “A form has three concerns”Five hooks presented as a flat list is five things to memorize. There’s a better way to hold them: a form has exactly three concerns, and each primitive answers one of them.
- Get values in and track them. Something has to own the live value of each field and keep RHF aware of it.
- Submit. Something has to intercept the submit, run validation, and hand off the result.
- Read the state back out. Something has to expose 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 those three concerns. The map below lays them out. Read it across, concern by concern, and notice where each hook lives.
useForm is the root: it’s called once and returns every other primitive. register and Controller get values in, handleSubmit submits, formState and watch read the state back out.
One structural fact in that picture matters more than any other: useForm is called once, and everything else is a property of what it returns. You don’t import register, control, handleSubmit, formState, watch, or reset separately, and you don’t call them independently. You call useForm, get back a form object, and reach into it. Get that one relationship right and the rest of the lesson is just learning what each property does.
useForm: the form’s container
Section titled “useForm: the form’s container”useForm is the single call at the top of your form component that creates the form’s state container: the one object that holds every field’s value, every error, and every flag. Because it’s a hook, the form is a Client Component ('use client' at the top of the file). That was already true in the last chapter, though, from the moment you used useActionState.
Here is the call, one piece at a time. Each step below highlights a different part.
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 the form used last chapter. Everything downstream (register('email'), the values handed to onSubmit) is typed from this.
const form = useForm<InvoiceInput>({ resolver: zodResolver(InvoiceSchema), defaultValues: { customer: '', email: '', total: 0 }, mode: 'onBlur',});The resolver is the function RHF calls to validate. zodResolver turns your Zod schema into one, so the form validates against the same schema the action parses with. You’ll wire this fully next lesson; for now, just 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. For a create form, every field starts empty ('', 0). This line is not optional in practice, and there’s more on why in a moment.
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 canonical “blur to see your error” UX, and the sensible default for any form 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.
Two of those options deserve a longer look, because they’re where beginners trip.
defaultValues is non-optional in practice. It’s tempting to skip it for a create form, since every field starts empty anyway. Here’s why you shouldn’t. An input with no default value renders as uncontrolled , but the instant the user types, RHF starts tracking a value for it, and the input flips to controlled. React notices a field that went from uncontrolled to controlled and logs a warning, which means you’ve shipped a subtle bug. Declaring defaultValues for every field means each one is controlled from the first render, so the flip never happens. For an edit form, the same line carries the row’s current values instead of empty strings, but the shape is identical.
mode is the validation-timing dial. It has five settings, and the difference between them is when the first error appears for a field:
mode: 'onSubmit' | 'onBlur' | 'onChange' | 'onTouched' | 'all';For most forms that crossed a trigger, reach for 'onBlur': it gives the “fill the field, leave it, see the error” feel without re-validating on every keystroke. There’s a sibling option, reValidateMode, that controls when validation re-runs after a field already has an error. You’ll meet it next lesson alongside the resolver. For now, mode is the one knob you need.
register: the uncontrolled path
Section titled “register: the uncontrolled path”register is how you wire a native input, and it’s the default you should reach for. The shape is one spread:
<input {...form.register('email')} />That spread puts four things onto the input: a name, a ref, an onChange, and an onBlur. The important one is the ref. With register, the DOM owns the live value, exactly like the uncontrolled inputs you already wrote in the last chapter. RHF doesn’t keep a copy of what the user typed; it reads the value off the DOM through that ref when it needs it, on submit and at whatever validation moment your mode picked. These are the same uncontrolled inputs from before, now with RHF reading them.
That’s also why register is the fast path. Because the value lives in the DOM and not in React state, typing into a registered input doesn’t re-render your form on every keystroke, since there’s no state to update. This is the concrete payoff of the “RHF keeps inputs uncontrolled by default” line from last lesson, and you’ll see it made literal a few sections down.
So the rule is simple: reach for register on every native input, whether 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.
There is one footgun worth showing, because it produces a form that silently stops working. If you need your own onChange on a registered input, to do something extra on each keystroke, the order you spread matters.
<input {...form.register('total')} onChange={(e) => setPreview(e.target.value)}/>Broken. Your onChange is declared after the spread, so it overwrites the onChange that register put there. RHF stops seeing the field change: the value never updates, and validation never fires.
<input onChange={(e) => setPreview(e.target.value)} {...form.register('total')}/>Correct. Spread register last so its onChange wins, and RHF’s handler runs. If you truly need both handlers, RHF hands you a combined one, but spreading last is the reflex.
The principle is “last spread wins”: JSX applies props left to right, so whatever comes last overwrites what came before. Spread register last and RHF’s handlers always survive.
Build the minimal form
Section titled “Build the minimal form”You now have enough to build a complete, working RHF form, before Controller, watch, or the design-system layer. Seeing it run end to end first makes everything after it easier to place. Here’s the whole thing: useForm at the top, two registered inputs, handleSubmit on the form, errors pulled 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 here on purpose. Without it there’s no validation yet, which is fine for a first look. It arrives next lesson, and the error rendering is already wired and ready for it.
That’s a real form. It tracks two fields, intercepts its own submit, calls the unchanged createInvoice, renders an email error when there is one, and shows “Saving…” while the action runs. Everything past this point is depth on top of this shape.
Now it’s your turn. The starter below has useForm called and defaultValues set, and 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: the controlled path
Section titled “Controller and useController: the controlled path”register needs a native input with a ref to hang onto. But many of the inputs a real SaaS form is full of, like shadcn’s Combobox, a Radix Select, a date picker, or a rich-text editor, don’t render a native <input name> at all. They own their value through value/onChange props, the controlled pattern you met as a trigger last lesson, so register can’t reach them. This is the gap Controller fills.
Controller is a bridge built as a render prop. You give it a field name and the form’s control, and it hands you back the wiring to plug into 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. The first, field, is the bundle RHF wants you to wire into the controlled component: { value, onChange, onBlur, name, ref }. You read field.value into the component’s value prop and route the component’s change callback into field.onChange, and now RHF owns that input’s value the same way it owns a registered one. The second, fieldState, is the per-field state for this one field: { invalid, error, isDirty, isTouched }. You’ll use it heavily once the design-system layer arrives, since it’s how a field knows to render itself as invalid. The control you pass in comes straight off the form object from useForm.
There’s a hook form of the exact same thing: useController({ name, control }) returns the identical field and fieldState. The choice between them is purely ergonomic. Reach for Controller (the render prop) for a one-off integration sitting in the form’s JSX. Reach for useController (the hook) inside a reusable field component that owns its own UI, say a project-wide <DatePickerField> that calls useController internally, so callers just pass name and control and never think about the wiring. Same bridge, two shapes.
The trap here is overreach. Once you learn Controller, it’s tempting to use it for everything, but wrapping a plain <input> that register would happily cover adds re-renders for nothing, because Controller makes the field controlled. Keep the line sharp:
handleSubmit: intercept, validate, hand off
Section titled “handleSubmit: intercept, validate, hand off”This is the primitive that finishes what last lesson left open. You saw the action prop disappear and form.handleSubmit(onSubmit) take its place on the <form>. Here’s what that call actually does.
handleSubmit returns a real DOM submit handler. When the form submits, that handler runs a small flow (intercept, validate, then branch) before any of your code sees the data. Scrub through it:
handleSubmit intercepts the native POST
The submit is caught in JS — preventDefault already fired, so no request has left the page.
resolver — the same Zod schema the action parses with
Before your code runs, RHF validates the values against the resolver — the client-side mirror of the action's safeParse.
One check, two exits: exactly one lane is taken — RHF decides which of your callbacks gets to run.
onSubmit(values) runs with the typed InvoiceInput
Valid: onSubmit receives clean, typed values — your code runs and calls createInvoice.
onSubmit is skipped — formState.errors is populated instead
Invalid: onSubmit never runs; RHF fills formState.errors so each field shows its message.
The shape on the page is just <form onSubmit={form.handleSubmit(onSubmit)}>, and onSubmit is your function. Because validation already ran by the time onSubmit is called, the values it receives are clean and typed, so you call the action directly with them:
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. One boundary point is worth repeating, because it’s easy to get backwards: 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. Mapping the action’s returned errors back into the form is its own small step that belongs with the resolver, so it stays a comment for one more lesson.
One seam needs calling out directly, because it changed from the last chapter. There, the submit button read its pending state with useFormStatus().pending. That worked because the form’s submit flowed through the action prop, and useFormStatus reads the status of that pending action. With RHF, the submit no longer goes through action; it goes through handleSubmit. So the canonical pending read is now form.formState.isSubmitting:
<Button type="submit" disabled={form.formState.isSubmitting}> {form.formState.isSubmitting ? 'Saving…' : 'Create invoice'}</Button>The reason the read changed is ownership: a different thing owns the submit now, so the flag that reports “submit in progress” lives in a different place. Don’t try to drop the last chapter’s <SubmitButton>, the one built on useFormStatus, into an RHF form. That component belongs to the native action-prop form. In an RHF form, formState.isSubmitting is the source of truth, and you’ll see it land in the canonical skeleton at the end.
formState: reading the form back out
Section titled “formState: reading the form back out”formState is the read side of the form, the object you reach into to render errors, disable buttons, and guard against losing unsaved work. Its members:
errorsholds the per-field errors, keyed by field name. This is what each field renders next to itself.isSubmittingis true whileonSubmitis in flight. It drives the submit button’sdisabled.isDirtyis true once the user has changed any field from its default. It backs the “you have unsaved changes” guard before navigating away.isValid,isSubmitSuccessful,touchedFields, anddirtyFieldsare situational. They’re useful when you need them, but not part of the daily three.
The three you’ll reach for in almost every form are errors, isSubmitting, and isDirty. The rest are there when a specific UX calls for them.
formState is also the heart of RHF’s performance story. It’s a proxy : when you read a property off it, RHF quietly records that you depend on that property, and it re-renders your component only when that property changes. This is a feature, but it has a sharp edge, so read only what you use. Destructure the properties you need (const { errors, isSubmitting } = form.formState) so your component subscribes to exactly those. Read the whole object, or read fields you don’t actually render, and you’ve subscribed to changes you don’t care about, paying re-renders for nothing.
This is also where the biggest misconception about forms falls apart: the idea that a form must re-render on every keystroke. With registered inputs, it doesn’t. Watch which boxes light up.
Flip between the two tabs and the lesson is in the badges. On the register tab, typing in one field lights up that field alone; the form root and its siblings stay cold, because the value lives in the DOM and there’s no React state to update. On the watch in the root tab, the same keystroke lights up the entire form, because reading a live value in the root subscribes the root to every change. Same form, same typing; the difference is where the value is read.
So the practical rule writes itself: keep watch and formState reads out of the form root. Push the read down into the small child that actually needs the value, which is exactly what the next primitive is for.
watch and useWatch: subscribing to live values
Section titled “watch and useWatch: subscribing to live values”Sometimes you genuinely need a field’s value as the user types, for a character counter, a “show the VAT field when country is EU” conditional, or a running total. That’s the controlled read side of RHF, and it has two shapes.
watch('email') returns the live value of a field and re-renders the calling component every time that field changes. It’s the quick reach, but, as the diagram just showed, calling it in the form root re-renders the whole form on every keystroke. So the version you should reach for is useWatch({ control, name }). It returns the same live value, but you call it inside a small child component, so only that child re-renders. Scope the subscription to the leaf that needs it, and the rest of the form stays still:
const CharCount = ({ control }: { control: Control<InvoiceInput> }) => { const note = useWatch({ control, name: 'note' }); return <span>{note?.length ?? 0} / 280</span>;};CharCount re-renders on every keystroke in the note field, and nothing else does. The form root, the other fields, and the submit button all stay stable. You pass control down as a prop and the child subscribes itself.
It’s worth saying why this is the lever, since you’re used to reaching for useMemo to control re-renders. This course runs with the React Compiler on, so you don’t hand-write memoization; the Compiler handles it. Re-render scope in RHF isn’t a memoization problem; it’s a subscription-placement problem. You don’t memoize the form to keep it still, you put the useWatch in a leaf so the subscription is small. Scope the subscription, don’t memoize the tree.
defaultValues and reset: the form’s identity
Section titled “defaultValues and reset: the form’s identity”You met defaultValues under useForm as the field set RHF tracks. Its partner is reset, and together they’re best understood as the form’s identity and lifecycle: what the form is, and how it returns to a known 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 as the defaults. The 2026 way to get those current values is worth stating plainly, because beginners reach for a client fetch: you don’t fetch them on the client. The Server Component fetches the entity and passes it as a prop to the Client Component form, and the form’s defaultValues reads from that prop:
export const EditInvoiceForm = ({ invoice }: { invoice: Invoice }) => { const form = useForm<InvoiceInput>({ defaultValues: { customer: invoice.customer, email: invoice.email, total: invoice.total, }, mode: 'onBlur', }); // ...};reset is the other half. form.reset(newValues?) re-sets the field values and clears the dirty and touched state, returning the form to a clean slate, optionally with new values. The canonical move is after a successful save: call reset(savedValues) so the form stays open showing exactly what was saved, with a clean dirty state and no lingering “unsaved changes.” This is RHF’s version of the last chapter’s “the edit form stays put after a successful save.” There the native form re-rendered with the action’s returned data as the defaultValue; here reset does the same job.
There’s a timing trap with reset that’s easy to hit and confusing to debug. If you call reset() inside onSubmit before the await resolves, you wipe the form before the action has even run: 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 even starts. The action still receives values, since you captured them, but the user watches their form blank out mid-save.
const onSubmit = async (values: InvoiceInput) => { const result = await createInvoice(values); form.reset(values);};Right. The save completes first, then reset(values) re-sets the form to the saved state with a clean dirty flag. (In real code you’d reset only on the success branch of result, which is next lesson.)
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: the label, the control, the description, and the error message, all laid out and spaced consistently. Back in the React chapters, you were told shadcn ships an older <Form> / <FormField> wrapper set that’s bound specifically to React Hook Form. Here’s the update that closes that loop. Shadcn now leads with a form-library-agnostic Field family (Field, FieldLabel, FieldDescription, FieldError, FieldGroup, FieldSet) used directly with RHF’s Controller. The same layout primitives now work whether the form underneath is RHF, TanStack Form, or a native action. The old <Form> wrapper still works and isn’t going away, but it’s no longer the recommended starting point, so this course uses Field + Controller.
Here’s the canonical field. Step through 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: the name keys it to the schema, and 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 same bundle you wired by hand in the Controller section, now just spread.
<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 the error presentation: data-invalid lets the Field style itself, aria-invalid tells assistive tech, and <FieldError> renders the message. Note it takes 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 sourced from field.name, so clicking the label focuses the input and screen readers announce them together. That’s the accessibility floor, kept intact.
A few notes to round it out. The per-field object fieldState ({ invalid, error, isDirty, isTouched }) is what makes the row reactive to its own validity, and it’s why Controller is the shadcn-documented path even though native inputs can pair register with a Field. Reading fieldState straight from the render prop keeps the error wiring local and clean. For grouping and spacing multiple fields, FieldGroup, FieldSet, and FieldLegend are the wrappers, the successors to the old <FormItem>, but you only need to recognize them for now.
The piece to remember from this section is fieldState : one field’s slice of state, handed to you exactly where you render that field.
The canonical form skeleton
Section titled “The canonical form skeleton”Now assemble everything into the one shape the rest of this chapter builds on. This is the artifact to be able to reproduce from memory, since every later lesson is a variation on it.
'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 one last time, and the five primitives fall into place:
useFormcreates the container (concern one), and produces every other primitive.Controllerandregisterget values in (concern one):Controllerfor the UI-library fields that fill the body,registerfor plain native inputs.handleSubmitowns the submit (concern two): intercept, validate against the resolver, hand the typedvaluestoonSubmit.formStatereads the state back out (concern three):isSubmittinghere drives the button, anderrorsdrives each field.watch/useWatchalso read state back out (concern three), in the leaves that need a live value.
A few pieces are left out on purpose, so the skeleton reads as honestly incomplete. The resolver is present but unexplained: its full 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 is the lesson after that, and the multi-step wizard after that, and both extend this same skeleton. You’ve got the spine; the chapter’s remaining lessons add the muscle.
Check your understanding
Section titled “Check your understanding”Here are two quick checks while the surface is fresh. First, place each primitive on its concern.
Match each RHF primitive to the job it does. Click an item on the left, then its match on the right. Press Check when done.
useFormregisterControllerhandleSubmitformStateuseWatchNow the decision you’ll 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.useState.register needs a native input it can hold by ref; a combobox doesn’t expose one, so Controller (or useController) is the bridge — you spread its field onto the component’s value/onChange. But Controller is not the default for everything: a plain <input> should use register, which avoids the extra re-renders Controller’s controlled wiring brings. Native input → register, value-owning component → Controller.External resources
Section titled “External resources”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.