How to make a controlled input.
value + onChange. The whole pattern.
A controlled input's value lives in React state — every keystroke updates the state, every render reflects the state. The pattern is two props: value and onChange. That's it. But there's a warning, a trade-off, and a set of gotchas. This page covers all of it.
Short answer: Add both props: setName(e.target.value)} />. The input's value is always name. Typing fires onChange, which updates state, which re-renders with the new value. Never use value without onChange — React warns because the input becomes read-only. For simple forms where you only need the value on submit, use an uncontrolled input with defaultValue instead.
✅ Solution 1 — The canonical pattern
value + onChange. Every controlled input follows this.
The input's value prop is always the state. The onChange handler updates the state. React re-renders with the new value — the input displays it. The DOM never manages the value itself; React does. This is the entire mental model.
import { useState } from 'react'; // ─── The canonical controlled input ────────── function SimpleForm() { const [name, setName] = useState(''); return ("text" value={name} // controlled onChange={e => setName(e.target.value)} // updates state placeholder="Your name" />); } // ─── Multiple fields: one state, one object ── interface FormData { name: string; email: string; } function MultiFieldForm() { const [form, setForm] = useStateHello, {name || 'stranger'}
({ name: '', email: '' }); function updateField extends keyof FormData>(key: K, value: FormData[K]) { setForm(prev => ({ ...prev, [key]: value })); } return ( updateField('name', e.target.value)} /> updateField('email', e.target.value)} />); } // ─── Different input types ─────────────────── function AllInputTypes() { const [text, setText] = useState(''); const [checked, setChecked] = useState(false); const [selected, setSelected] = useState('a'); const [textarea, setTextarea] = useState(''); return ({/* Text input — value is string */} setText(e.target.value)} /> {/* Checkbox — checked is boolean */} "checkbox" checked={checked} onChange={e => setChecked(e.target.checked)} /> {/* Select — value is string */} {/* Textarea — same as text */}); } // ─── The pattern in one line ───────────────── // // value={state} onChange={e => setState(e.target.value)} // // Two props. Every controlled input. No exceptions. console.log(SimpleForm, MultiFieldForm, AllInputTypes);
import { useState } from 'react'; function SimpleForm() { const [name, setName] = useState(''); return React.createElement('div', null, React.createElement('input', { type: 'text', value: name, onChange: e => setName(e.target.value), placeholder: 'Your name' }), React.createElement('p', null, 'Hello, ', name || 'stranger') ); } function MultiFieldForm() { const [form, setForm] = useState({ name: '', email: '' }); function updateField(key, value) { setForm(prev => ({ ...prev, [key]: value })); } return React.createElement('div', null, React.createElement('input', { value: form.name, onChange: e => updateField('name', e.target.value) }), React.createElement('input', { value: form.email, onChange: e => updateField('email', e.target.value) }) ); } function AllInputTypes() { const [text, setText] = useState(''); const [checked, setChecked] = useState(false); const [selected, setSelected] = useState('a'); const [textarea, setTextarea] = useState(''); return React.createElement('div', null, React.createElement('input', { value: text, onChange: e => setText(e.target.value) }), React.createElement('input', { type: 'checkbox', checked, onChange: e => setChecked(e.target.checked) }), React.createElement('select', { value: selected, onChange: e => setSelected(e.target.value) }, React.createElement('option', { value: 'a' }, 'A'), React.createElement('option', { value: 'b' }, 'B') ), React.createElement('textarea', { value: textarea, onChange: e => setTextarea(e.target.value) }), React.createElement('input', { type: 'radio', checked: selected === 'a', onChange: () => setSelected('a') }), React.createElement('input', { type: 'number', value: Number(text) || 0, onChange: e => setText(e.target.value) }) ); } console.log(SimpleForm, MultiFieldForm, AllInputTypes);
Two props, always
value + onChange together. Missing either breaks the pattern — the input is either uncontrolled or read-only.
e.target.value
For text-like inputs (text, textarea, select). For checkbox/radio use e.target.checked (boolean).
Number inputs are strings
e.target.value is always a string, even for type="number". Convert with Number() or parseFloat().
One state, one object
For many fields, use one state object. A typed updater handles all fields without duplication.
✅ Solution 2 — The "value without onChange" warning
Why it appears, and the two correct fixes.
If you provide value without onChange, React warns: "You provided a value prop to a form field without an onChange handler." The input becomes read-only — the user can't type because there's no way for the value to change. The fix depends on intent: add onChange (if you meant to control it), or use defaultValue (if you meant it to be uncontrolled).
import { useState } from 'react'; // ─── BAD: value without onChange ───────────── function BadReadOnly() { return ( "hello" /> {/* ⚠️ Warning: provided a value prop without onChange → input is read-only. User can't type. → React is warning about a UX bug. */} ); } // ─── FIX 1: add onChange if you meant to control function GoodControlled() { const [value, setValue] = useState('hello'); return ( setValue(e.target.value)} // ✅ can now type /> ); } // ─── FIX 2: use defaultValue if you meant uncontrolled function GoodUncontrolled() { return ( "hello" /> {/* ✅ user can type, value starts at "hello" → the DOM manages the value internally → read it at submit with a ref or FormData */} ); } // ─── BAD: value and defaultValue together function BadBoth() { return ( "hello" defaultValue="world" /> {/* ⚠️ Warning: both value and defaultValue provided → React can't decide who owns the value */} ); } // ─── BAD: value={undefined} ────────────────── function BadUndefined() { const [value] = useState<string | undefined>(undefined); return ; {/* ⚠️ value={undefined} → uncontrolled with a null initial → React treats it as uncontrolled, but the intent is unclear. Fix: use value={value ?? ''} to always pass a string. */} } // ─── GOOD: default empty string ────────────── function GoodNullable() { const [value] = useState<string | undefined>(undefined); return ( ''} // ✅ always a string onChange={() => {}} /> ); } // ─── The rule ──────────────────────────────── // // value + onChange → controlled (recommended) // defaultValue → uncontrolled // value only → read-only + warning // value + defaultValue → error + warning // value={undefined} → uncontrolled with null — usually a bug // // Choose controlled by default. Switch to uncontrolled // for file inputs (can't be controlled) or very large // forms where per-keystroke re-renders hurt performance. console.log(BadReadOnly, GoodControlled, GoodUncontrolled, BadBoth, BadUndefined, GoodNullable);
import { useState } from 'react'; function BadReadOnly() { return React.createElement('input', { value: 'hello' }); } function GoodControlled() { const [value, setValue] = useState('hello'); return React.createElement('input', { value, onChange: e => setValue(e.target.value) }); } function GoodUncontrolled() { return React.createElement('input', { defaultValue: 'hello' }); } function BadBoth() { return React.createElement('input', { value: 'hello', defaultValue: 'world' }); } function BadUndefined() { const [value] = useState(undefined); return React.createElement('input', { value }); } function GoodNullable() { const [value] = useState(undefined); return React.createElement('input', { value: value ?? '', onChange: () => {} }); } console.log(BadReadOnly, GoodControlled, GoodUncontrolled, BadBoth, BadUndefined, GoodNullable);
Warning = real UX bug
The input can't be typed in. User sees a read-only field. React is protecting you from shipping this.
Fix by intent
Add onChange if you want to control. Use defaultValue if you want the DOM to own the value.
Never both
value + defaultValue — React warns because it can't decide ownership. Pick one.
Nullable state
value={x ?? ''} for possibly-null values. Never pass undefined to value — behavior is confusing.
✅ Solution 3 — Controlled form with submit
The full pattern — inputs, submit, validation, reset.
A real form combines controlled inputs, validation, submit handling, and reset. The state is one object; the submit handler validates and sends; the reset clears. This is the pattern every production form uses.
import { useState } from 'react'; interface FormData { name: string; email: string; message: string; } interface FormErrors { name?: string; email?: string; message?: string; } function ContactForm() { const [form, setForm] = useState({ name: '', email: '', message: '' }); const [errors, setErrors] = useState ({}); const [submitted, setSubmitted] = useState(false); function updateField extends keyof FormData>(key: K, value: FormData[K]) { setForm(prev => ({ ...prev, [key]: value })); // Clear that field's error on change setErrors(prev => ({ ...prev, [key]: undefined })); } function validate(data: FormData): FormErrors { const errs: FormErrors = {}; if (!data.name.trim()) errs.name = 'Name is required'; if (!data.email.includes('@')) errs.email = 'Invalid email'; if (data.message.length < 10) errs.message = 'Message must be 10+ characters'; return errs; } async function handleSubmit(e: React.FormEvent ) { e.preventDefault(); const errs = validate(form); if (Object.keys(errs).length > 0) { setErrors(errs); return; } await fetch('/api/contact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) }); setSubmitted(true); setForm({ name: '', email: '', message: '' }); } if (submitted) { return Thanks for contacting us!
; } return ( ); } // ─── Alternative: FormData (uncontrolled) ──── function UncontrolledForm() { function handleSubmit(e: React.FormEvent) { e.preventDefault(); const data = new FormData(e.currentTarget); const name = data.get('name') as string; const email = data.get('email') as string; console.log({ name, email }); } return ( ); } // ─── When to use which ─────────────────────── // // ✅ Controlled (value + onChange): // - Live validation // - Conditional UI based on input // - Dynamic initial values (from API, stored draft) // - Programmatic reset (setX('')) // - Complex forms with inter-field dependencies // // ✅ Uncontrolled (defaultValue + FormData): // - Simple forms (name, email, message) // - File inputs (files can't be controlled) // - Very large forms (hundreds of fields) // - Performance-critical (no per-keystroke renders) // // React recommends controlled by default. // Uncontrolled is for cases where the trade-off pays off. console.log(ContactForm, UncontrolledForm);
import { useState } from 'react'; function ContactForm() { const [form, setForm] = useState({ name: '', email: '', message: '' }); const [errors, setErrors] = useState({}); const [submitted, setSubmitted] = useState(false); function updateField(key, value) { setForm(prev => ({ ...prev, [key]: value })); setErrors(prev => ({ ...prev, [key]: undefined })); } function validate(data) { const errs = {}; if (!data.name.trim()) errs.name = 'Name is required'; if (!data.email.includes('@')) errs.email = 'Invalid email'; if (data.message.length < 10) errs.message = 'Message must be 10+ characters'; return errs; } async function handleSubmit(e) { e.preventDefault(); const errs = validate(form); if (Object.keys(errs).length > 0) { setErrors(errs); return; } await fetch('/api/contact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) }); setSubmitted(true); setForm({ name: '', email: '', message: '' }); } if (submitted) { return React.createElement('p', null, 'Thanks for contacting us!'); } return React.createElement('form', { onSubmit: handleSubmit }, React.createElement('div', null, React.createElement('input', { value: form.name, onChange: e => updateField('name', e.target.value), placeholder: 'Name' }), errors.name && React.createElement('span', { style: { color: 'red' } }, errors.name) ), React.createElement('div', null, React.createElement('input', { type: 'email', value: form.email, onChange: e => updateField('email', e.target.value), placeholder: 'Email' }), errors.email && React.createElement('span', { style: { color: 'red' } }, errors.email) ), React.createElement('div', null, React.createElement('textarea', { value: form.message, onChange: e => updateField('message', e.target.value), placeholder: 'Message' }), errors.message && React.createElement('span', { style: { color: 'red' } }, errors.message) ), React.createElement('button', { type: 'submit' }, 'Send') ); } function UncontrolledForm() { function handleSubmit(e) { e.preventDefault(); const data = new FormData(e.currentTarget); const name = data.get('name'); const email = data.get('email'); console.log({ name, email }); } return React.createElement('form', { onSubmit: handleSubmit }, React.createElement('input', { name: 'name', defaultValue: '' }), React.createElement('input', { name: 'email', type: 'email', defaultValue: '' }), React.createElement('button', { type: 'submit' }, 'Send') ); } console.log(ContactForm, UncontrolledForm);
One state, one object
All fields in one useState. The typed updater handles every field without duplication.
Validate on submit
Run validation, populate errors, return early if invalid. No need to validate on every keystroke.
Clear errors on change
When a field changes, clear its error. The user sees the error disappear as they fix the input.
Reset after submit
setForm({ ...initial }) clears everything. Or show a "success" screen.
For complex forms, use a library. React Hook Form, TanStack Form, and Formik handle validation, error display, submission state, and reset — with less code and better performance. Controlled inputs are the foundation, but once a form has 10+ fields with cross-field validation, the manual pattern becomes painful. React Hook Form is the modern default: it's uncontrolled by default (fast), and its useForm hook handles everything. Learn the controlled pattern first — then reach for the library when you need it.
⚠️ Common mistakes
The controlled input bugs that cost real time.
Warning, input is read-only. Fix: add onChange or use defaultValue.
type="number" still returns a string from e.target.value. Fix: Number(e.target.value).
e.target.checked for text inputschecked is only for checkbox/radio. Text inputs use e.target.value.
form.name = 'x' — no re-render. Fix: setForm(f => ({ ...f, name })).
value on type="file" doesn't work — files can't be set programmatically. Fix: uncontrolled with a ref.
value={undefined} is confusing. Fix: value={value ?? ''} — always a string.
Errors flash while typing. Fix: validate on submit, or debounce validation.
preventDefaultForm submit reloads the page. Fix: e.preventDefault() at the top of the submit handler.
❓ Frequently asked
How do I make a controlled input in React?
Add both value and onChange props: setName(e.target.value)} />. The input's displayed value is always the state variable. Typing fires onChange, which updates state, which re-renders with the new value.
Why does React warn about "value without onChange"?
Because the input is read-only. Without onChange, there's no way to update the state that controls value. React detects that the value is set but nothing can change it, so typing does nothing.
Should I use controlled or uncontrolled inputs?
Controlled when you need to react to every keystroke: validation, live search, conditional rendering. Uncontrolled when you just need the value on submit: simple forms, file inputs, or performance-critical inputs. React's default recommendation is controlled.
What is the difference between value and defaultValue?
value is the controlled value — it's always set from state. defaultValue is the initial value — after mount, the DOM manages the value internally. Use value + onChange for controlled, defaultValue for uncontrolled.