Smart form component
Build Form and field components once, with field names checked against your form type.
Most forms in an app repeat the same structure: a <form> that calls handleSubmit, and fields that each render a label, an input, and an error message. Move that structure into components once, and each form becomes a short list of fields.
The challenge is keeping the components type-safe. A generic <TextField name="..." /> accepts any string, so a typo in a field name only shows up at runtime. The pattern below creates components bound to one form type, so TypeScript checks every name.
A typed form factory
createForm<T>() returns a set of components that know your form's values type. Form creates the form and provides it through FormProvider, and each field reads it with useFormContext:
// create-form.tsximport type { ReactNode } from "react"import {FormProvider,get,useForm,useFormContext,useFormState,type DefaultValues,type FieldError,type FieldPath,type FieldValues,type RegisterOptions,type Resolver,type SubmitHandler,} from "react-hook-form"export function createForm<T extends FieldValues>() {function Form({defaultValues,resolver,onSubmit,children,}: {defaultValues: DefaultValues<T>resolver?: Resolver<T>onSubmit: SubmitHandler<T>children: ReactNode}) {const methods = useForm<T>({ defaultValues, resolver })return (<FormProvider {...methods}><form noValidate onSubmit={methods.handleSubmit(onSubmit)}>{children}</form></FormProvider>)}// Subscribes to one field's error, so a new error re-renders only that field.function useFieldError(name: FieldPath<T>) {const { errors } = useFormState<T>({ name })return get(errors, name) as FieldError | undefined}function TextField({name,label,type = "text",rules,}: {name: FieldPath<T>label: stringtype?: "text" | "email" | "password" | "tel" | "url"rules?: RegisterOptions<T, FieldPath<T>>}) {const { register } = useFormContext<T>()const error = useFieldError(name)return (<div><label htmlFor={name}>{label}</label><inputid={name}type={type}aria-invalid={!!error}aria-describedby={error ? `${name}-error` : undefined}{...register(name, rules)}/>{error?.message && (<p id={`${name}-error`} role="alert">{error.message}</p>)}</div>)}function SelectField({name,label,options,rules,}: {name: FieldPath<T>label: stringoptions: readonly { value: string; label: string }[]rules?: RegisterOptions<T, FieldPath<T>>}) {const { register } = useFormContext<T>()const error = useFieldError(name)return (<div><label htmlFor={name}>{label}</label><selectid={name}aria-invalid={!!error}aria-describedby={error ? `${name}-error` : undefined}{...register(name, rules)}>{options.map((option) => (<option key={option.value} value={option.value}>{option.label}</option>))}</select>{error?.message && (<p id={`${name}-error`} role="alert">{error.message}</p>)}</div>)}function SubmitButton({ children }: { children: ReactNode }) {const { isSubmitting } = useFormState<T>()return (<button type="submit" disabled={isSubmitting}>{children}</button>)}return { Form, TextField, SelectField, SubmitButton }}
Using it
import { createForm } from "./create-form"type SignupValues = {firstName: stringemail: stringplan: "free" | "pro"}// Create the components once, outside any component.const Signup = createForm<SignupValues>()export default function SignupForm({onSubmit,}: {onSubmit: (values: SignupValues) => Promise<void>}) {return (<Signup.FormdefaultValues={{ firstName: "", email: "", plan: "free" }}onSubmit={onSubmit}><Signup.TextFieldname="firstName"label="First name"rules={{ required: "Enter your first name." }}/><Signup.TextFieldname="email"label="Email"type="email"rules={{ required: "Enter your email address." }}/><Signup.SelectFieldname="plan"label="Plan"options={[{ value: "free", label: "Free" },{ value: "pro", label: "Pro" },]}/><Signup.SubmitButton>Create account</Signup.SubmitButton></Signup.Form>)}// @ts-expect-error "emial" is not a field of SignupValues.export const typo = <Signup.TextField name="emial" label="Email" />
How it fits together:
nameis typed asFieldPath<SignupValues>, so"emial"is a type error.- Fields can be nested anywhere inside
Signup.Form, in your own layout components, because they read the form from context. - Each field calls
useFormState({ name }), so a new error re-renders only that field, not the whole form. SubmitButtonsubscribes only toisSubmitting.- Every field renders a label,
aria-invalid, and a linked error message, so each form gets accessible errors for free.
Call createForm at module level, not inside a component. Calling it during render would create new component types on every render, and React would remount every field.
Extending it
- Controlled components: add a field that calls
useController, such as a date picker or the star rating in Controlled and uncontrolled inputs. - Schema validation: pass
resolver={zodResolver(schema)}toForm, and leave outrules. - Access to the methods: add a
useFormhook to the factory that returnsuseFormContext<T>(), as in Connect form. - Your design system: replace the plain elements with your own input components. Forward the
reffromregisterto the native input so focus on error works.
Why not clone children?
An older version of this pattern had Form loop over its children with React.Children.map and inject register into any child with a name prop. Avoid it:
- It only reaches direct children. A field inside a
<div>or a layout component is skipped. nameis an untyped string, and the injected props are invisible to TypeScript.- Cloning elements is fragile, and the React docs recommend alternatives.
Context, as above, reaches fields at any depth and keeps them typed.