App shells get users to a screen. Forms are where trust is won or lost - validation timing, focus on errors, and whether React re-renders every keystroke because someone wrapped the world in controlled state.
I pick controlled vs uncontrolled with criteria, not hype. Form libraries pay off only when the criteria say the hand-rolled path is lying.
Definitions that matter
Controlled: React state is the source of truth. value + onChange on
every keystroke (or a library that syncs the same idea).
Uncontrolled: The DOM holds the value until you read it (ref, FormData,
or a library in uncontrolled mode). React may set a defaultValue, then step
back.
tsx// Controlled: React owns the string function ControlledName() { const [name, setName] = useState(''); return ( <input value={name} onChange={(e) => setName(e.target.value)} aria-label="Name" /> ); } // Uncontrolled: DOM owns the string until submit function UncontrolledName() { const ref = useRef<HTMLInputElement>(null); return ( <form onSubmit={(e) => { e.preventDefault(); const name = ref.current?.value ?? ''; console.info(name); }} > <input ref={ref} name="name" defaultValue="" aria-label="Name" /> <button type="submit">Save</button> </form> ); }
Neither is morally superior. They optimize for different update rates and validation shapes.
When controlled wins
Use controlled inputs when:
- Other UI depends on the value while typing (password strength meter, live character count, dependent fields)
- You need to format or block input as it arrives (not just on blur)
- Multiple controls must stay in sync (mirrored fields, query-param filters bound to inputs)
- You are driving the field from server state after load and must reset cleanly when the query identity changes
tsx// Dependent fields, controlled pays off function PriceFields() { const [currency, setCurrency] = useState<'USD' | 'IDR'>('USD'); const [amount, setAmount] = useState(''); return ( <> <select value={currency} onChange={(e) => setCurrency(e.target.value as 'USD' | 'IDR')} aria-label="Currency" > <option value="USD">USD</option> <option value="IDR">IDR</option> </select> <input value={amount} onChange={(e) => setAmount(e.target.value)} inputMode="decimal" aria-label={`Amount in ${currency}`} /> </> ); }
When uncontrolled wins
Prefer uncontrolled when:
- Values are read at submit (classic HTML forms, multipart uploads)
- The form is large and per-keystroke React updates buy nothing
- You integrate with non-React widgets that own their DOM value
- You want native browser behavior with minimal interference
tsx// FormData path, simple create forms function CreateNoteForm({ onCreate }: { onCreate: (title: string) => void }) { return ( <form onSubmit={(e) => { e.preventDefault(); const data = new FormData(e.currentTarget); const title = String(data.get('title') ?? '').trim(); if (!title) return; onCreate(title); e.currentTarget.reset(); }} > <input name="title" required aria-label="Title" /> <button type="submit">Create</button> </form> ); }
Wire errors with aria-invalid / aria-describedby either way, uncontrolled
does not mean inaccessible.
Drafts vs server state (client/server boundaries reprise)
Form drafts are client state until the mutation succeeds. Do not stuff partial keystrokes into TanStack Query. Do not treat Query cache as a form store.
After success: invalidate or setQueryData, then reset local form state. That
is the boundary.
When a form library pays off
I reach for a library (React Hook Form, Conform, etc.) when two or more of these are true:
- Many fields with shared validation schema
- Field arrays (repeatable line items) that hurt to maintain by hand
- Need minimal re-renders on large forms (RHF uncontrolled-by-default)
- Schema-first validation (Zod/Valibot) shared with the server
- Complex error mapping from API responses onto fields
tsx// illustrative: RHF + zodResolver shape (library pays off here) import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; const schema = z.object({ email: z.string().email(), quantity: z.coerce.number().int().positive(), }); type Values = z.infer<typeof schema>; export function OrderForm({ onSubmit }: { onSubmit: (v: Values) => void }) { const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm<Values>({ resolver: zodResolver(schema) }); return ( <form onSubmit={handleSubmit(onSubmit)}> <input aria-invalid={!!errors.email} aria-describedby={errors.email ? 'email-error' : undefined} {...register('email')} /> {errors.email ? ( <p id="email-error" role="alert" > {errors.email.message} </p> ) : null} <input type="number" aria-invalid={!!errors.quantity} {...register('quantity')} /> <button type="submit" disabled={isSubmitting} > Place order </button> </form> ); }
A three-field newsletter form does not need this. A quote builder with line items probably does.
Failure modes
Everything controlled by default. Typing latency and noisy renders; nobody can explain why.
Library on every form "for consistency." Consistency without criteria is cargo cult, and a bigger bundle for marketing pages.
Validating only on submit with no focus move. Screen reader and keyboard users get a dead button experience. Move focus to the first invalid field.
Inventing success rates. I will not claim a library "cut bugs 40%." The honest win is fewer custom field-array bugs you can point at in review.
Continuity
Responsive layout got the user to the form. Controlled vs uncontrolled picks the state model inside it. Toasts and notifications cover how success/failure messages leave the form without becoming a second UI framework.
Takeaway
Controlled when the UI must react while typing; uncontrolled when the DOM can hold the pencil until submit, libraries pay off for schema-heavy or high-field forms, not for ideology.