Tier 3 · SpecializedRecipe #10

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

actions.ts
'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 }
})
checkout-form.tsx — Root component
'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>
  )
}
CustomerSection.tsx — Sub-component
'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>
  )
}
ShippingSection.tsx — Sub-component with Controller
'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 FormProvider

The <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 paths

Register 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-components

control 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 a FormProvider. Adding another one around it creates nested contexts and causes useFormContext() to read from the innermost (empty) provider.

  • Sub-components using useFormContext outside <Form>

    If a sub-component is rendered outside the <Form> tree (e.g. in a portal, a story, or a test), useFormContext() will return undefined and throw. Wrap test cases in a mock FormProvider.

  • Mismatched dot-notation path and schema shape

    If you register customer.name but the Zod schema expects customerName (flat), the field will not validate correctly and the error path will not match. Keep the schema and the register paths in sync.

Related