// LOADING
// LOADING
// LOADING_ARTICLE
Forms are where user intent becomes data. They are also where React developers spend a disproportionate amount of time fighting re-renders, validation timing, and state synchronization. Getting form architecture right upfront saves significant refactoring later.
A controlled input is one where React drives the value. The component holds state; every keystroke calls a setter; the input reflects whatever is in state.
tsxfunction SearchBox() { const [query, setQuery] = useState(''); return ( <input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." /> ); }
Controlled inputs give you immediate access to the current value for things like live filtering, character counting, or dependent field logic. The trade-off is that every keystroke triggers a re-render. For a single input, this is irrelevant. For a complex form with many fields and expensive child components, it can accumulate.
A common mitigation is lifting state minimally — keep each field's state local to that field rather than in a single form-level object, so a change to one field does not re-render all others.
An uncontrolled input lets the DOM own the value. React reads it only when needed, via a ref.
tsxfunction ContactForm() { const nameRef = useRef<HTMLInputElement>(null); const emailRef = useRef<HTMLInputElement>(null); function handleSubmit(e: React.FormEvent) { e.preventDefault(); const name = nameRef.current?.value ?? ''; const email = emailRef.current?.value ?? ''; submitContact({ name, email }); } return ( <form onSubmit={handleSubmit}> <input ref={nameRef} type="text" name="name" /> <input ref={emailRef} type="email" name="email" /> <button type="submit">Send</button> </form> ); }
Uncontrolled inputs generate zero re-renders during typing. They are the right choice when you only need the value on submit and have no real-time validation or dependent field logic. For simple contact forms or single-field search submissions, they are often the correct default.
With React 19 and Next.js Server Actions, there is a third path that requires no refs and no state:
tsx// Server Action async function handleContact(formData: FormData) { 'use server'; const name = formData.get('name') as string; const email = formData.get('email') as string; await saveContact({ name, email }); } // Client Component export default function ContactPage() { return ( <form action={handleContact}> <input name="name" type="text" required /> <input name="email" type="email" required /> <button type="submit">Send</button> </form> ); }
The browser handles submission; FormData is constructed natively; validation falls back to HTML5 attributes. For server-rendered forms, this is the leanest possible approach and it works without JavaScript for the base case (progressive enhancement). See Route Handlers vs Server Actions for a deeper comparison of these patterns.
React Hook Form (RHF) is the right choice when you need:
RHF's register API attaches refs to inputs, keeping the form uncontrolled while providing a clean interface for validation rules and error messages:
tsximport { useForm } from 'react-hook-form'; type FormValues = { name: string; email: string; message: string; }; function ContactForm() { const { register, handleSubmit, formState: { errors }, } = useForm<FormValues>(); function onSubmit(data: FormValues) { console.log(data); } return ( <form onSubmit={handleSubmit(onSubmit)}> <input {...register('name', { required: 'Name is required' })} type="text" /> {errors.name && <span>{errors.name.message}</span>} <input {...register('email', { required: 'Email is required', pattern: { value: /^[^@]+@[^@]+$/, message: 'Invalid email' }, })} type="email" /> {errors.email && <span>{errors.email.message}</span>} <button type="submit">Send</button> </form> ); }
RHF integrates cleanly with Zod via @hookform/resolvers. Defining your schema once in Zod means you get TypeScript types for free, and the same schema can validate on the client and on the server:
tsximport { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; const schema = z.object({ name: z.string().min(2, 'Name must be at least 2 characters'), email: z.string().email('Invalid email address'), message: z.string().min(10, 'Message too short'), }); type FormValues = z.infer<typeof schema>; function ContactForm() { const { register, handleSubmit, formState: { errors } } = useForm<FormValues>({ resolver: zodResolver(schema), }); // ... }
The schema drives both the TypeScript types and the validation rules. For more on using Zod beyond forms, Runtime Validation in TypeScript with Zod covers parsing API responses and environment variables with the same approach.
A simple heuristic:
Do not default to a form library out of habit. The extra dependency has a cost — in bundle size, in abstraction layer, in onboarding time. Reach for it when the form's complexity justifies it.
Form libraries handle value management; they do not handle accessibility. Labels, error announcements, and focus management are your responsibility regardless of which approach you use. Every input needs an associated label (via htmlFor or wrapping), error messages need role="alert" or aria-live to be announced to screen readers, and focus should move to the first error on failed submission. For a broader treatment of this topic, Building Accessible React Components covers the ARIA patterns that apply directly to form UX.
Forms in React are not one-size-fits-all. Start with the simplest approach that meets your requirements — native FormData for server-mutating forms, uncontrolled refs for submit-only client forms, controlled state for live-updating UIs — and graduate to React Hook Form when the complexity warrants it. Whatever you choose, layer in schema validation with Zod and take accessibility seriously from the start.