Nested Fields & Sub-components
Compose large forms from smaller, focused components using useFormContext — without passing register, errors, or control as props through every level.
Why it matters
Large forms — checkout, onboarding, profile settings — are easier to maintain when split into focused sub-components. But React Hook Form's register, control, and formState normally need to be prop-drilled down the tree, which creates tight coupling and verbose component signatures.
The <Form> component from hookform-action wraps its children in React Hook Form's FormProvider automatically. Any component inside the tree can call useFormContext() to access the full RHF API without any prop passing.
Full Example — Checkout Form
'use server'
import { z } from 'zod'
import { withZod } from 'hookform-action-core/with-zod'
export const checkoutSchema = z.object({
customer: z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Enter a valid email'),
}),
shipping: z.object({
street: z.string().min(1, 'Street is required'),
city: z.string().min(1, 'City is required'),
country: z.string().min(2, 'Select a country'),
}),
})
export type CheckoutValues = z.infer<typeof checkoutSchema>
export const checkoutAction = withZod(checkoutSchema, async (data) => {
await processOrder(data)
return { success: true }
})'use client'
import { useActionForm } from 'hookform-action'
import { Form } from 'hookform-action-core' // or from 'hookform-action'
import { checkoutAction } from './actions'
import type { CheckoutValues } from './actions'
import { CustomerSection } from './CustomerSection'
import { ShippingSection } from './ShippingSection'
export function CheckoutForm() {
// useActionForm returns the full RHF API plus action integration
const form = useActionForm<CheckoutValues>(checkoutAction, {
defaultValues: {
customer: { name: '', email: '' },
shipping: { street: '', city: '', country: '' },
},
})
return (
// <Form> automatically wraps children in <FormProvider>
// Sub-components can use useFormContext() without any prop passing
<Form form={form}>
<CustomerSection />
<ShippingSection />
<button
type="submit"
disabled={form.formState.isPending}
className="w-full py-3 bg-brand-600 hover:bg-brand-500 text-white rounded-lg font-medium"
>
{form.formState.isPending ? 'Processing…' : 'Place Order'}
</button>
</Form>
)
}'use client'
import { useFormContext } from 'react-hook-form'
import type { CheckoutValues } from './actions'
export function CustomerSection() {
// No props needed — reads form context from the parent <Form>
const {
register,
formState: { errors },
} = useFormContext<CheckoutValues>()
return (
<section className="mb-6">
<h3 className="text-lg font-semibold mb-4">Customer Info</h3>
<div className="space-y-4">
<div>
<label htmlFor="name" className="text-sm text-gray-400">Full Name</label>
<input
id="name"
{...register('customer.name')} // dot-notation for nested path
placeholder="Jane Doe"
/>
{errors.customer?.name && (
<p className="text-red-400 text-sm">{errors.customer.name.message}</p>
)}
</div>
<div>
<label htmlFor="email" className="text-sm text-gray-400">Email</label>
<input
id="email"
type="email"
{...register('customer.email')}
placeholder="jane@example.com"
/>
{errors.customer?.email && (
<p className="text-red-400 text-sm">{errors.customer.email.message}</p>
)}
</div>
</div>
</section>
)
}'use client'
import { Controller, useFormContext } from 'react-hook-form'
import type { CheckoutValues } from './actions'
export function ShippingSection() {
const {
register,
control, // ← needed for Controller / useFieldArray in sub-components
formState: { errors },
} = useFormContext<CheckoutValues>()
return (
<section className="mb-6">
<h3 className="text-lg font-semibold mb-4">Shipping Address</h3>
<div className="space-y-4">
<div>
<label className="text-sm text-gray-400">Street</label>
<input {...register('shipping.street')} placeholder="123 Main St" />
{errors.shipping?.street && (
<p className="text-red-400 text-sm">{errors.shipping.street.message}</p>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm text-gray-400">City</label>
<input {...register('shipping.city')} placeholder="New York" />
{errors.shipping?.city && (
<p className="text-red-400 text-sm">{errors.shipping.city.message}</p>
)}
</div>
<div>
<label className="text-sm text-gray-400">Country</label>
{/* Controller example — for custom select components */}
<Controller
name="shipping.country"
control={control}
render={({ field }) => (
<select {...field}>
<option value="">Select…</option>
<option value="US">United States</option>
<option value="BR">Brazil</option>
</select>
)}
/>
{errors.shipping?.country && (
<p className="text-red-400 text-sm">{errors.shipping.country.message}</p>
)}
</div>
</div>
</div>
</section>
)
}Key Concepts
<Form form={form}> — automatic FormProviderThe <Form> component from hookform-action wraps children in RHF's FormProvider automatically. You do not need to add FormProvider yourself. All sub-components in the tree can call useFormContext().
useFormContext<TFieldValues>()Pass the form's type parameter to get typed access to register, errors, and control. Without the generic, you get FieldValues (essentially any).
Dot-notation for nested pathsRegister deeply nested fields with dot notation: register('customer.name'). RHF will collect these into a properly structured object. The Zod schema's shape must match exactly.
control in sub-componentscontrol is needed for Controller and useFieldArray inside sub-components. Get it from useFormContext() — not passed as a prop from the parent.
⚠️ Pitfalls
- ⚠
Double-wrapping with
FormProvider<Form>already includes aFormProvider. Adding another one around it creates nested contexts and causesuseFormContext()to read from the innermost (empty) provider. - ⚠
Sub-components using
useFormContextoutside<Form>If a sub-component is rendered outside the
<Form>tree (e.g. in a portal, a story, or a test),useFormContext()will returnundefinedand throw. Wrap test cases in a mockFormProvider. - ⚠
Mismatched dot-notation path and schema shape
If you register
customer.namebut the Zod schema expectscustomerName(flat), the field will not validate correctly and the error path will not match. Keep the schema and theregisterpaths in sync.