Skip to content

Connect form

Give deeply nested inputs access to form methods.

When inputs live deep in a component tree, passing register, control, and formState through every level gets noisy. Wrap the form in FormProvider and read the methods anywhere below it with useFormContext.

A typed hook per form

useFormContext needs your form's type as a generic. Instead of repeating useFormContext<CheckoutValues>() in every component, wrap it in a hook named after the form. The hook is also a good place to fail loudly: outside a FormProvider, useFormContext returns null, and the first register call would otherwise throw a confusing error.

A render-prop connector

A ConnectForm component passes the methods to a function child. It is handy for one-off inputs inside JSX, or in code that cannot call hooks directly, such as a class component's render method.

import type { ReactNode } from "react"
import {
FormProvider,
useForm,
useFormContext,
type FieldValues,
type UseFormReturn,
} from "react-hook-form"
type CheckoutValues = {
email: string
shipping: { street: string; city: string }
}
// A typed hook for this form, so components don't repeat the generic.
export function useCheckoutForm() {
const methods = useFormContext<CheckoutValues>()
if (!methods) {
throw new Error("useCheckoutForm must be used inside <FormProvider>.")
}
return methods
}
// A render-prop version for places where you can't call a hook.
export function ConnectForm<T extends FieldValues>({
children,
}: {
children: (methods: UseFormReturn<T>) => ReactNode
}) {
return children(useFormContext<T>())
}
function ShippingAddress() {
const {
register,
formState: { errors },
} = useCheckoutForm()
return (
<fieldset>
<legend>Shipping address</legend>
<label htmlFor="street">Street</label>
<input
id="street"
{...register("shipping.street", { required: "Enter a street." })}
/>
{errors.shipping?.street && (
<p role="alert">{errors.shipping.street.message}</p>
)}
<label htmlFor="city">City</label>
<input
id="city"
{...register("shipping.city", { required: "Enter a city." })}
/>
</fieldset>
)
}
export default function Checkout({
onSubmit,
}: {
onSubmit: (values: CheckoutValues) => void
}) {
const methods = useForm<CheckoutValues>({
defaultValues: { email: "", shipping: { street: "", city: "" } },
})
return (
<FormProvider {...methods}>
<form noValidate onSubmit={methods.handleSubmit(onSubmit)}>
<ConnectForm<CheckoutValues>>
{({ register }) => (
<>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register("email")} />
</>
)}
</ConnectForm>
<ShippingAddress />
<button type="submit">Place order</button>
</form>
</FormProvider>
)
}

ShippingAddress uses the typed hook, so register("shipping.street") is checked against CheckoutValues, and a typo is a type error. The email input uses the render-prop connector inline.

When to use which

SituationUse
A component inside one known formA typed hook such as useCheckoutForm
Inline JSX, or a component that cannot call hooksConnectForm
A reusable input used in many different formsPass control and name as props and call useController
A component that only displays a value or form stateuseWatch or useFormState, which accept control or read it from context

Reading formState from context subscribes the component the same way reading it from useForm does. To keep re-renders low, read only what the component renders. See FormProvider performance.

Nested providers

Each useFormContext reads the nearest FormProvider above it. If you nest two forms, for example a dialog form inside a page form, components inside the dialog read the inner one. Render the inner form outside the outer <form> element, such as in a portal, because HTML does not allow nested <form> elements.