React 25 ⚛️ Controlled Form Inputs
A form input that holds its own state is unpredictable. The DOM remembers what the user typed, but your React component does not, unless you ask it. When you need to validate, transform, or react to input as it happens, you must take control of the value. This is the controlled component pattern: the input’s value is driven by React state, and every keystroke flows through an event handler that updates that state. The input becomes a reflection of your component’s data, not an independent store of truth.
This chapter covers controlled form inputs in React. You will learn how the value and onChange pair creates a controlled input, how this differs from uncontrolled inputs that rely on the DOM, and how to handle different input types—text, textarea, select, checkbox, radio, and file. You will see how to build reusable form components with a single change handler, how to validate and transform input as it is typed, and how to avoid the common pitfalls that produce read-only fields or laggy typing.
By the end, you will understand controlled inputs as the foundation of predictable forms in React, and you will know when an uncontrolled input is the better choice.
Key point: In a controlled input, React state is the single source of truth. The input’s value prop is set from state, and the onChange handler updates that state. If you set value without onChange, the input becomes read-only because React re-renders it to the same value on every keystroke.
Why controlled inputs exist
The single source of truth problem. In plain HTML, an input holds its own value. If your JavaScript needs to know what the user typed, it must read the DOM. In React, the component’s state is the source of truth for rendering. If state and the DOM can disagree, bugs follow: a form reset leaves the input unchanged, a validation message contradicts what the user sees, an undo does not restore the previous value. Controlled inputs eliminate this by making state the only source of the value.
The reactivity problem. Controlled inputs let you respond to every keystroke. You can validate as the user types, format a phone number, disable a submit button until the form is valid, or filter a list based on the current query. Uncontrolled inputs do not offer this without reading the DOM imperatively.
The consistency problem. A controlled input always displays what state says it should display. When the component re-renders—because a parent passed new props, because a sibling changed, because a timer fired—the input shows the current state. There is no way for the DOM to drift out of sync.
The testing problem. Controlled components are easier to test because the value is a prop, and the change handler is a function. You can render the component with a given value and assert that the DOM reflects it. You can call the change handler with a synthetic event and assert that state updates.
The form library foundation. Every form library in React—Formik, React Hook Form, TanStack Form—builds on the controlled input pattern, sometimes with uncontrolled performance optimizations underneath. Understanding controlled inputs is understanding the foundation that these libraries abstract.
a. Text inputs and the value/onChange pair
A controlled text input sets its value from state and updates that state in onChange.
import { useState } from 'react';
function NameForm() {
const [name, setName] = useState('');
return (
<form>
<label>
Name:
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</label>
<p>Hello, {name || 'stranger'}!</p>
</form>
);
}
The value prop binds the input to name. The onChange handler reads e.target.value—the current text in the input—and calls setName with it. React re-renders, the input receives the new value, and the displayed text matches state.
The e.target.value access is important. The event object is React’s synthetic event, which wraps the native event. e.target is the input element, and value is its current value. This is the standard pattern for text inputs.
If you set value but forget onChange, the input becomes read-only. React re-renders the input with the same value after every keystroke, effectively undoing the user’s input. This is a common mistake and produces an input that looks broken.
If you omit value entirely, the input is uncontrolled. It works, but React cannot read or set its value without a ref. This is fine for simple cases but loses the benefits of controlled inputs.
b. Textarea, select, and other input types
React handles <textarea> differently from HTML. In HTML, <textarea> uses its children as the value. In React, it uses the value prop, just like <input>.
function BioForm() {
const [bio, setBio] = useState('');
return (
<textarea
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={4}
/>
);
}
A <select> uses value and onChange on the select element itself.
function FruitPicker() {
const [fruit, setFruit] = useState('apple');
return (
<select value={fruit} onChange={(e) => setFruit(e.target.value)}>
<option value="apple">Apple</option>
<option value="banana">Banana</option>
<option value="cherry">Cherry</option>
</select>
);
}
The value on the <select> matches one of the <option> values. React selects the matching option. In HTML you would use selected on the option; in React, you set value on the select.
Checkboxes use checked instead of value.
function TermsCheckbox() {
const [agreed, setAgreed] = useState(false);
return (
<label>
<input
type="checkbox"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
/>
I agree to the terms
</label>
);
}
The handler reads e.target.checked, which is a boolean, not e.target.value.
Radio buttons use checked and a shared state value.
function ColorPicker() {
const [color, setColor] = useState('red');
return (
<div>
{['red', 'green', 'blue'].map((c) => (
<label key={c}>
<input
type="radio"
value={c}
checked={color === c}
onChange={(e) => setColor(e.target.value)}
/>
{c}
</label>
))}
</div>
);
}
Each radio has the same name if you want them to be mutually exclusive natively, but React does not require it. The checked prop compares the current state to the radio’s value.
File inputs are special. Their value cannot be set programmatically for security reasons, so they are always uncontrolled. You read the file via a ref or the event’s files property.
function FileUpload() {
const [fileName, setFileName] = useState('');
const handleChange = (e) => {
setFileName(e.target.files[0]?.name ?? '');
};
return (
<div>
<input type="file" onChange={handleChange} />
{fileName && <p>Selected: {fileName}</p>}
</div>
);
}
You can track the file name in state, but the file input itself remains uncontrolled.
c. Reusable handlers and form state
When a form has many fields, one state variable per field becomes verbose. A common pattern is a single state object with a generic change handler.
function SignupForm() {
const [form, setForm] = useState({
name: '',
email: '',
password: ''
});
const handleChange = (e) => {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = (e) => {
e.preventDefault();
console.log(form);
};
return (
<form onSubmit={handleSubmit}>
<input
name="name"
value={form.name}
onChange={handleChange}
placeholder="Name"
/>
<input
name="email"
type="email"
value={form.email}
onChange={handleChange}
placeholder="Email"
/>
<input
name="password"
type="password"
value={form.password}
onChange={handleChange}
placeholder="Password"
/>
<button type="submit">Sign up</button>
</form>
);
}
The handler reads e.target.name to know which field changed, and e.target.value for the new value. It updates the state object using the computed property name. This pattern scales to any number of text-like inputs.
The functional update form setForm((prev) => ({ ...prev, [name]: value })) is important. It ensures the update is based on the latest state, which matters when multiple updates are batched. It also avoids stale closures when the handler is memoized.
For checkboxes in a multi-field form, the handler must distinguish between checked and value:
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setForm((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
This handles both text and checkbox inputs with one handler.
Validation can be computed from the form state and rendered conditionally:
const errors = {
email: !form.email.includes('@') ? 'Invalid email' : '',
password: form.password.length < 8 ? 'Too short' : ''
};
The errors object is derived from state, not stored separately. This keeps the source of truth single and avoids sync bugs.
Complete Example Session
// ============================================
// PART 1: BASIC CONTROLLED TEXT INPUT
// ============================================
import { useState } from 'react';
function NameForm() {
const [name, setName] = useState('');
return (
<input
value={name}
onChange={(e) => setName(e.target.value)}
/>
);
}
// ============================================
// PART 2: CONTROLLED TEXTAREA
// ============================================
function BioForm() {
const [bio, setBio] = useState('');
return (
<textarea
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={4}
/>
);
}
// ============================================
// PART 3: CONTROLLED SELECT
// ============================================
function FruitPicker() {
const [fruit, setFruit] = useState('apple');
return (
<select value={fruit} onChange={(e) => setFruit(e.target.value)}>
<option value="apple">Apple</option>
<option value="banana">Banana</option>
</select>
);
}
// ============================================
// PART 4: CONTROLLED CHECKBOX
// ============================================
function TermsCheckbox() {
const [agreed, setAgreed] = useState(false);
return (
<label>
<input
type="checkbox"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
/>
I agree
</label>
);
}
// ============================================
// PART 5: CONTROLLED RADIO GROUP
// ============================================
function ColorPicker() {
const [color, setColor] = useState('red');
return (
<div>
{['red', 'green', 'blue'].map((c) => (
<label key={c}>
<input
type="radio"
value={c}
checked={color === c}
onChange={(e) => setColor(e.target.value)}
/>
{c}
</label>
))}
</div>
);
}
// ============================================
// PART 6: SINGLE STATE OBJECT
// ============================================
function SignupForm() {
const [form, setForm] = useState({
name: '',
email: '',
password: ''
});
const handleChange = (e) => {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
};
return (
<form>
<input name="name" value={form.name} onChange={handleChange} />
<input name="email" value={form.email} onChange={handleChange} />
<input name="password" value={form.password} onChange={handleChange} />
</form>
);
}
// ============================================
// PART 7: HANDLER FOR CHECKBOX AND TEXT
// ============================================
function MixedForm() {
const [form, setForm] = useState({ email: '', subscribe: false });
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setForm((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
return (
<form>
<input name="email" value={form.email} onChange={handleChange} />
<label>
<input
type="checkbox"
name="subscribe"
checked={form.subscribe}
onChange={handleChange}
/>
Subscribe
</label>
</form>
);
}
// ============================================
// PART 8: VALIDATION FROM STATE
// ============================================
function ValidatedForm() {
const [email, setEmail] = useState('');
const isValid = email.includes('@') && email.includes('.');
return (
<div>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
{!isValid && email && <p>Invalid email</p>}
<button disabled={!isValid}>Submit</button>
</div>
);
}
// ============================================
// PART 9: TRANSFORMING INPUT
// ============================================
function PhoneInput() {
const [phone, setPhone] = useState('');
const handleChange = (e) => {
const digits = e.target.value.replace(/\D/g, '');
const formatted = digits.replace(
/(\d{3})(\d{3})(\d{4})/,
'($1) $2-$3'
);
setPhone(formatted);
};
return <input value={phone} onChange={handleChange} />;
}
// ============================================
// PART 10: FILE INPUT (UNCONTROLLED)
// ============================================
function FileUpload() {
const [fileName, setFileName] = useState('');
const handleChange = (e) => {
setFileName(e.target.files[0]?.name ?? '');
};
return (
<div>
<input type="file" onChange={handleChange} />
{fileName && <p>Selected: {fileName}</p>}
</div>
);
}
The ten parts covered the full range of controlled inputs: text, textarea, select, checkbox, radio, a single state object with a generic handler, a handler that distinguishes checkbox from text, validation derived from state, input transformation, and the file input as an exception.
Quick Reference
Controlled Input Pattern
| Element | Value Prop | Change Handler Reads |
|---|---|---|
<input type="text"> | value | e.target.value |
<textarea> | value | e.target.value |
<select> | value | e.target.value |
<input type="checkbox"> | checked | e.target.checked |
<input type="radio"> | checked | e.target.value |
<input type="file"> | N/A (uncontrolled) | e.target.files |
Controlled vs Uncontrolled
| Aspect | Controlled | Uncontrolled |
|---|---|---|
| Source of truth | React state | DOM |
| Value prop | Required | Omitted |
| Change handler | Required | Optional |
| Read value | From state | From ref |
| Validation as typing | Yes | No |
| Form reset | Set state | Use ref or key |
| File inputs | Not possible | Required |
Form State Patterns
| Pattern | When to Use |
|---|---|
One useState per field | Small forms (2–3 fields) |
| Single state object | Medium forms (4+ fields) |
| Generic handler | Any form with named inputs |
useReducer | Complex form logic |
| Form library | Large or complex forms |
Common Input Types
| Type | Controlled Prop | Handler |
|---|---|---|
| text | value | setValue(e.target.value) |
value | setValue(e.target.value) | |
| password | value | setValue(e.target.value) |
| number | value | setValue(e.target.value) |
| checkbox | checked | setChecked(e.target.checked) |
| radio | checked | setValue(e.target.value) |
| file | — | e.target.files |
Best Practices
✅ Do This:
// Always pair value with onChange
<input value={name} onChange={(e) => setName(e.target.value)} /> // ✅
// Use e.target.checked for checkboxes
<input type="checkbox" checked={ok}
onChange={(e) => setOk(e.target.checked)} /> // ✅
// Use a single state object for many fields
const [form, setForm] = useState({ name: '', email: '' }); // ✅
// Use functional updates in the handler
setForm((prev) => ({ ...prev, [name]: value })); // ✅
// Derive validation from state
const isValid = email.includes('@'); // ✅
// Use name attributes for generic handlers
<input name="email" value={form.email} onChange={handleChange} /> // ✅
❌ Don’t Do This:
// Set value without onChange (read-only)
<input value={name} /> // ❌
// Use value with checkbox
<input type="checkbox" value={ok} /> // ❌
// Read e.target.value for checkbox
setOk(e.target.value); // ❌ (use checked)
// Store validation in separate state
const [isValid, setIsValid] = useState(false); // ⚠️ (derive instead)
// Try to control file input
<input type="file" value={file} /> // ❌ (not allowed)
// Forget the functional update when memoizing
const handleChange = useCallback((e) => {
setForm({ ...form, [e.target.name]: e.target.value });
}, [form]); // ⚠️ (stale risk)
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Input is read-only | value set without onChange | Add onChange handler |
| Typing laggy | Expensive computation in onChange | Debounce or move to useMemo |
| Checkbox toggles wrong | Reading value instead of checked | Use e.target.checked |
| Select doesn’t update | value not matching option value | Match exactly |
| State stale in handler | Closure over old state | Use functional update |
| File input error | value prop on file input | Remove value, read files |
| Form does not reset | State not cleared | Reset all state fields |
Real-World Examples
1. Login Form
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
2. Search Input
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
3. Multi-Field Signup
const [form, setForm] = useState({ name: '', email: '', password: '' });
4. Checkbox Agreement
<input
type="checkbox"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
/>
5. Radio Group
{options.map((opt) => (
<label key={opt}>
<input
type="radio"
value={opt}
checked={selected === opt}
onChange={(e) => setSelected(e.target.value)}
/>
{opt}
</label>
))}
6. Select Dropdown
<select value={country} onChange={(e) => setCountry(e.target.value)}>
{countries.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
7. Textarea with Character Count
<textarea
value={bio}
onChange={(e) => setBio(e.target.value)}
maxLength={200}
/>
<p>{bio.length}/200</p>
8. Formatted Phone Number
const digits = e.target.value.replace(/\D/g, '');
9. Disabled Submit Button
<button disabled={!isValid}>Submit</button>
10. Reset Form
const reset = () => {
setForm({ name: '', email: '', password: '' });
};
Visual
Controlled Input Data Flow
┌─────────────────────────────────────────────────────────────┐
│ CONTROLLED INPUT FLOW │
│ │
│ User types "H" │
│ │ │
│ ▼ │
│ onChange fires │
│ │ │
│ ▼ │
│ setName(e.target.value) │
│ │ │
│ ▼ │
│ State updates: name = "H" │
│ │ │
│ ▼ │
│ React re-renders │
│ │ │
│ ▼ │
│ <input value="H" /> │
│ │ │
│ ▼ │
│ Input displays "H" │
│ │
│ State is the single source of truth. │
│ │
└─────────────────────────────────────────────────────────────┘
Read-Only Bug
┌─────────────────────────────────────────────────────────────┐
│ VALUE WITHOUT ONCHANGE │
│ │
│ <input value={name} /> │
│ │
│ User types "H" │
│ │ │
│ ▼ │
│ No onChange → no state update │
│ │ │
│ ▼ │
│ React re-renders with value={name} (unchanged) │
│ │ │
│ ▼ │
│ Input reverts to original value │
│ │
│ Result: input appears read-only. │
│ │
└─────────────────────────────────────────────────────────────┘
Controlled vs Uncontrolled
┌─────────────────────────────────────────────────────────────┐
│ CONTROLLED │
│ │
│ React state ──▶ value ──▶ input │
│ ▲ │ │
│ └──── onChange ◀────────┘ │
│ │
│ State drives display. Every change flows through React. │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ UNCONTROLLED │
│ │
│ input ──▶ DOM value (React does not track) │
│ │
│ Read via ref when needed. React does not re-render │
│ on change. │
│ │
└─────────────────────────────────────────────────────────────┘
Form State Object
┌─────────────────────────────────────────────────────────────┐
│ SINGLE STATE OBJECT │
│ │
│ const [form, setForm] = useState({ │
│ name: '', │
│ email: '', │
│ password: '' │
│ }); │
│ │
│ handleChange(e): │
│ const { name, value } = e.target; │
│ setForm(prev => ({ ...prev, [name]: value })); │
│ │
│ Each input: │
│ <input name="email" │
│ value={form.email} │
│ onChange={handleChange} /> │
│ │
│ One handler updates any field by name. │
│ │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Pattern | value={state} + onChange={handler} |
| Text input | value + e.target.value |
| Textarea | Same as text input in React |
| Select | value on select, e.target.value |
| Checkbox | checked + e.target.checked |
| Radio | checked + e.target.value |
| File input | Uncontrolled; read e.target.files |
| Single state object | useState({...}) for many fields |
| Generic handler | Read e.target.name to update field |
| Functional update | setForm(prev => ({...prev, [name]: value})) |
| Validation | Derived from state, not stored |
Key takeaways:
- Controlled inputs make React state the single source of truth. The
valueprop is set from state, and theonChangehandler updates that state. The input always reflects what state says. valuewithoutonChangemakes the input read-only. React re-renders the input with the same value on every keystroke, undoing the user’s input. Both props are required for a controlled input.- Different input types use different props and event fields. Text inputs use
valueande.target.value. Checkboxes and radios usecheckedande.target.checkedore.target.value. File inputs cannot be controlled. - A single state object scales to many fields. Store form data in one object, use
e.target.nameto identify the field, and update with a functional spread. This avoids oneuseStateper field. - The functional update prevents stale state.
setForm(prev => ({...prev, [name]: value}))uses the latest state, which matters when updates are batched or the handler is memoized. - Validation should be derived, not stored. Compute validity from the form state on each render. Storing it in separate state creates sync problems.
- Input transformation happens in the handler. To format a phone number or uppercase a field, transform
e.target.valuebefore callingsetState. The transformed value flows back to the input throughvalue. - File inputs are always uncontrolled. Their value cannot be set programmatically. Track metadata like the file name in state if needed, but read the file itself from the event or a ref.
Remember: Controlled inputs are the foundation of predictable forms in React. They make the component the source of truth, enable validation and transformation as the user types, and keep the displayed value in sync with the data. The pattern is simple: value from state, onChange to update state. The complexity comes from the variety of input types and the need for a scalable handler. Use a single state object with a generic handler for forms with many fields, derive validation from state, and remember that file inputs are the exception. When the form is small and you do not need to react to input, an uncontrolled input with a ref is a reasonable choice. For everything else, control the input, and the form will behave predictably.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!