Controlled vs Uncontrolled
A controlled input has its value driven by React state — every keystroke triggers onChange, which updates state, which re-renders the input. The opposite is uncontrolled (the DOM owns the value, you reach in via a ref). Controlled is the default for forms with validation, normalisation, or cross-field rules; uncontrolled wins for file inputs and simple one-off submissions.
Controlled form with validation + a Zod schema
EXAMPLE
import { useState, useRef } from 'react';
import { z } from 'zod';
// 1) Controlled inputs — state is the source of truth
const Schema = z.object({
email: z.string().email('Invalid email'),
name: z.string().min(2, 'Name is too short'),
age: z.coerce.number().int().min(18, 'Must be 18+'),
agree: z.literal(true, { errorMap: () => ({ message: 'Required' }) }),
});
type FormValues = z.input<typeof Schema>;
const initial: FormValues = { email: '', name: '', age: 0 as any, agree: false };
export default function Signup() {
const [values, setValues] = useState<FormValues>(initial);
const [errors, setErrors] = useState<Partial<Record<keyof FormValues, string>>>({});
const [submitting, setSubmitting] = useState(false);
function set<K extends keyof FormValues>(k: K, v: FormValues[K]) {
setValues((prev) => ({ ...prev, [k]: v }));
}
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
const parsed = Schema.safeParse(values);
if (!parsed.success) {
const next: typeof errors = {};
for (const issue of parsed.error.issues) next[issue.path[0] as keyof FormValues] = issue.message;
setErrors(next);
return;
}
setErrors({});
setSubmitting(true);
try {
await fetch('/api/signup', { method: 'POST', body: JSON.stringify(parsed.data) });
setValues(initial);
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={onSubmit} noValidate className='space-y-2'>
<Field label='Email' error={errors.email}>
<input value={values.email}
onChange={(e) => set('email', e.target.value)}
type='email' autoComplete='email' className='input' />
</Field>
<Field label='Name' error={errors.name}>
<input value={values.name}
onChange={(e) => set('name', e.target.value)}
className='input' />
</Field>
<Field label='Age' error={errors.age}>
<input value={values.age as any}
onChange={(e) => set('age', e.target.value as any)}
inputMode='numeric' className='input' />
</Field>
<Field error={errors.agree}>
<label className='inline-flex items-center gap-2'>
<input type='checkbox' checked={values.agree}
onChange={(e) => set('agree', e.target.checked)} />
I agree to the terms
</label>
</Field>
<button disabled={submitting} className='btn'>{submitting ? 'Saving...' : 'Create account'}</button>
</form>
);
}
function Field({ label, error, children }: { label?: string; error?: string; children: React.ReactNode }) {
return (
<div>
{label && <label className='block text-sm'>{label}</label>}
{children}
{error && <p className='text-red-600 text-sm'>{error}</p>}
</div>
);
}
// 2) Uncontrolled — file inputs and very simple forms
function UploadAvatar() {
const ref = useRef<HTMLInputElement>(null);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
const file = ref.current?.files?.[0];
if (!file) return;
const body = new FormData();
body.append('file', file);
await fetch('/api/avatar', { method: 'POST', body });
}
return (
<form onSubmit={onSubmit}>
<input ref={ref} type='file' accept='image/*' />
<button>Upload</button>
</form>
);
}
// 3) Performance — debounce expensive validation
// A controlled input with synchronous schema parsing on every keystroke
// is fine; debounce only when validation crosses the network.
// 4) Forms libraries — React Hook Form / Formik
// Once you have 5+ fields, reach for react-hook-form: it tracks dirty / touched,
// uses a single ref-based subscription per field, and integrates with Zod via
// @hookform/resolvers/zod.
Why it matters
Controlled inputs make validation and cross-field rules trivial; the cost is a re-render per keystroke. For large forms, prefer react-hook-form — it keeps the controlled mental model but subscribes per field, so typing in one input does not re-render the others, which keeps frame budgets healthy on long forms.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// Controlled — React owns the value.
<input value={text} onChange={e => setText(e.target.value)} />
// Uncontrolled — DOM owns it; read via ref.
Try it Yourself »
Discussion
Loading…