Forms — Cheat Sheet
React · 6 topics. Download the PDF or the Instagram carousel and share it.
Controlled and Uncontrolled Inputs
A controlled input renders from state and updates it on every keystroke. An uncontrolled input keeps its own value in the DOM. Mixing them produces React's most familiar warning.
- ✓A controlled input renders from state and updates it on every keystroke, which enables live validation and formatting
- ✓An uncontrolled input keeps its value in the DOM and is read via FormData on submit — no renders while typing
- ✓defaultValue seeds an uncontrolled input; value makes it controlled
- ✓Starting state as undefined or null makes an input switch from uncontrolled to controlled, producing React's classic warning
- ✓A file input cannot be controlled, and large forms often prefer uncontrolled for performance
const [email, setEmail] = useState('')
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
// Because state owns it, you can act on every keystroke
<input
value={pin}
onChange={(e) => setPin(e.target.value.replace(/\D/g, '').slice(0, 6))}
/>
// digits only, max six — the user cannot type anything else
// The other input types
<input type="checkbox" checked={agreed} onChange={e => setAgreed(e.target.checked)} />
<select value={level} onChange={e => setLevel(e.target.value)}>
<textarea value={notes} onChange={e => setNotes(e.target.value)} />
// Several fields in one object
const [values, setValues] = useState({ name: '', email: '' })
const onChange = (e) =>
setValues(v => ({ ...v, [e.target.name]: e.target.value }))
<input name="email" value={values.email} onChange={onChange} />Validation — Rules, Timing and Messages
Define the rules once as a schema, validate on blur rather than on every keystroke, and never treat client validation as security.
- ✓Define validation rules once as a schema, which in TypeScript also produces the values type
- ✓Validate on blur, then on change once a field has already errored, and everything on submit
- ✓Move focus to the first invalid field on submit, and link messages with aria-describedby
- ✓Client validation is convenience only — the server rule is the real one and can always reject
- ✓Map server 422 errors back onto individual fields and never clear the user's input on failure
import { z } from 'zod'
const SignupSchema = z.object({
name: z.string().min(2, 'Please enter your name'),
email: z.string().email('That does not look like an email'),
password: z.string()
.min(8, 'At least 8 characters')
.regex(/[0-9]/, 'Include a number'),
confirm: z.string(),
age: z.coerce.number().int().min(13, 'You must be 13 or older'),
}).refine(v => v.password === v.confirm, {
message: 'Passwords do not match',
path: ['confirm'], // attaches the error to the right field
})
type SignupValues = z.infer<typeof SignupSchema> // the type, for free
// Validating gives you either the parsed values or field errors
const result = SignupSchema.safeParse(values)
if (!result.success) setErrors(result.error.flatten().fieldErrors)
// The same schema can run on a Node backend, which is the strongest
// version of "define the rules once".React Hook Form
The library nearly every React job uses for forms. It keeps inputs uncontrolled for speed, wires validation to a schema, and removes the boilerplate you would otherwise write for every field.
- ✓register wires an input straight to the DOM, so typing does not re-render the form
- ✓handleSubmit runs validation first and only calls your handler with valid values
- ✓formState gives errors, isSubmitting, isDirty and isValid — the flags you would otherwise track by hand
- ✓setError puts server failures into the same display as client validation errors
- ✓Controller adapts components that expose their own value/onChange instead of a DOM ref
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isDirty, isValid },
reset,
setError,
} = useForm({
resolver: zodResolver(SignupSchema), // the schema from the last concept
defaultValues: { name: '', email: '' },
mode: 'onBlur', // the timing convention
})
async function onSubmit(values) { // only runs if validation passed
await signup(values)
reset()
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} aria-invalid={!!errors.email} />
{errors.email && <p role="alert">{errors.email.message}</p>}
<button disabled={isSubmitting || !isDirty}>
{isSubmitting ? 'Creating…' : 'Create account'}
</button>
</form>
)
}
// Typing re-renders nothing. Only error state changes cause renders.Submission — Pending, Success and Failure
What happens between the click and the outcome is where forms are won or lost: no double submits, no lost input, and a result the user can actually see.
- ✓Disable and relabel the submit button while the request is in flight, setting the flag before the await
- ✓Enter in a text field submits too — disabling on pending covers every path to a double submit
- ✓On success, confirm what happened, refresh the affected data, and move the user forward
- ✓On failure never clear the form, map errors to fields where possible, and re-enable the button
- ✓Move focus to the error message so screen-reader users hear it, and autosave drafts for long forms
<button type="submit" disabled={isSubmitting} aria-busy={isSubmitting}>
{isSubmitting ? 'Saving…' : 'Save changes'}
</button>
// Disabled alone is not enough feedback. A label change or spinner
// tells the user the click registered.
// Reserve the width so the button does not resize between the two
// labels, which reads as a jitter:
<span className="min-w-[8ch] inline-block text-center">…</span>
// Prevent the other paths to a double submit:
// - Enter in a text field submits the form too
// - a second click during the request
// Both are covered by disabling on isSubmitting, provided the flag
// is set before the await:
setSubmitting(true)
try { await save(values) } finally { setSubmitting(false) }
// For anything that creates a record, an idempotency key makes the
// duplicate harmless even if one slips through.File Upload
A file input cannot be controlled, uploads need progress that fetch cannot report, and at any real size the file should not pass through your API at all.
- ✓A file input is always uncontrolled — you cannot set its value from state
- ✓Object URLs must be revoked on cleanup or the selected file stays in memory
- ✓fetch cannot report upload progress; XMLHttpRequest's upload.onprogress can
- ✓Client-side type and size checks are convenience only — the server must verify the real content
- ✓Production uploads go directly to storage via a short-lived signed URL, with the signing endpoint as the security boundary
const [file, setFile] = useState(null)
const [preview, setPreview] = useState(null)
function onChange(e) {
const f = e.target.files?.[0]
if (!f) return
if (!['image/jpeg', 'image/png', 'image/webp'].includes(f.type))
return setError('Please choose a JPEG, PNG or WebP image')
if (f.size > 5 * 1024 * 1024)
return setError('Images must be under 5MB')
setFile(f)
setPreview(URL.createObjectURL(f)) // an object URL, not a data URL
}
// Object URLs must be revoked or the file stays in memory
useEffect(() => () => { if (preview) URL.revokeObjectURL(preview) }, [preview])
<input type="file" accept="image/*" onChange={onChange} />
// accept filters the OS picker; it does not enforce anything.
// The extension and the MIME type are both attacker-controlled —
// the server must verify the real content.Multi-Step Forms and Wizards
One state object, per-step validation, and a step in the URL. The hard parts are surviving a refresh and letting the user go backwards without losing anything.
- ✓Keep every answer in one object and merge each step's values rather than replacing them
- ✓Put the step in the URL so the back button, refresh and shared links all behave
- ✓Validate each step's own schema on leaving it, then validate the merged schema before the final submit
- ✓Returning to a previous step must show exactly what the user typed — pass the values back as defaults
- ✓Persist the draft but never persist passwords, card numbers or OTPs, and clear it when the flow completes
const STEPS = ['account', 'profile', 'goals', 'review']
function Onboarding() {
const [params, setParams] = useSearchParams()
const step = STEPS.indexOf(params.get('step') ?? 'account')
const [values, setValues] = useState(() => loadDraft() ?? {})
const goTo = (i) => setParams({ step: STEPS[i] }) // a history entry
const next = (stepValues) => {
setValues(v => ({ ...v, ...stepValues })) // merge, never replace
goTo(step + 1)
}
return (
<>
<Progress current={step} total={STEPS.length} />
{step === 0 && <AccountStep defaults={values} onNext={next} />}
{step === 1 && <ProfileStep defaults={values} onNext={next} onBack={() => goTo(0)} />}
{step === 3 && <Review values={values} onSubmit={submitAll} />}
</>
)
}
// Step in the URL means: back button works, refresh keeps position,
// and a support conversation can say "go to ?step=goals".