How Does React Handle Forms and Controlled Inputs?
Advertisement
🧩 Scenario
Architecture Walkthrough
The Controlled Input Cycle
A controlled input has four steps that happen on every keystroke: the user types a character, onChange fires with e.target.value containing the new text, the state setter is called with the new value, React re-renders the component, and the input displays the state value. React state is the single source of truth; the DOM reflects it.
This cycle enables every form feature that requires knowing the current value at any moment: live validation, character counting, input formatting, conditional field visibility, dependent field logic, and submit button state. None of these are possible without controlled inputs because with uncontrolled inputs, the value lives in the DOM and is only accessible via a ref read on demand.
Multi-Field Forms with a Shared Handler
For forms with multiple fields, writing a separate onChange for each field is repetitive. A shared handler reads e.target.name to identify which field changed and uses a computed property key to update only that field: setForm(prev => ({ ...prev, [name]: value })). Spreading prev is critical: without it, setting one field replaces the entire form state object, wiping out all other fields.
Real-Time Validation and Formatting
Validation can run inside onChange, immediately showing errors as the user types rather than only on submit. Formatting (auto-inserting dashes in a phone number, grouping digits in a credit card) is also done in onChange: strip the raw input to just digits, apply the formatting logic, and set the formatted string as state. The input then renders the formatted value on the next frame.
Key Code Explained
// Multi-field form with shared handler and real-time validation
interface SignupForm {
email: string;
password: string;
confirmPassword: string;
}
interface FormErrors {
email?: string;
password?: string;
confirmPassword?: string;
}
function SignupForm() {
const [form, setForm] = useState<SignupForm>({
email: '',
password: '',
confirmPassword: '',
});
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
// Shared handler: reads e.target.name to identify the field
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
// Update only the changed field — spread prev to preserve others
setForm((prev) => ({ ...prev, [name]: value }));
// Real-time validation
setErrors((prev) => {
const next = { ...prev };
if (name === 'email') {
next.email = value.includes('@') ? undefined : 'Enter a valid email';
}
if (name === 'password') {
next.password = value.length >= 8 ? undefined : 'Minimum 8 characters';
}
if (name === 'confirmPassword') {
next.confirmPassword =
value === form.password ? undefined : 'Passwords do not match';
}
return next;
});
};
const hasErrors = Object.values(errors).some(Boolean);
const isComplete = form.email && form.password && form.confirmPassword;
const canSubmit = isComplete && !hasErrors && !isSubmitting;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!canSubmit) return;
setIsSubmitting(true);
// await submitToApi(form);
setIsSubmitting(false);
};
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email" // name matches the form state key
type="email"
value={form.email}
onChange={handleChange}
/>
{errors.email && <p className="error">{errors.email}</p>}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
value={form.password}
onChange={handleChange}
/>
{errors.password && <p className="error">{errors.password}</p>}
</div>
<div>
<label htmlFor="confirmPassword">Confirm Password</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
value={form.confirmPassword}
onChange={handleChange}
/>
{errors.confirmPassword && (
<p className="error">{errors.confirmPassword}</p>
)}
</div>
{/* Submit only enabled when form is valid */}
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? 'Creating account...' : 'Sign Up'}
</button>
</form>
);
}
// Input formatting: phone number auto-formatting in onChange
function PhoneInput() {
const [phone, setPhone] = useState('');
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Strip all non-digits first
const digits = e.target.value.replace(/\D/g, '').slice(0, 10);
// Insert dashes at correct positions
let formatted = digits;
if (digits.length > 6) {
formatted = `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6)}`;
} else if (digits.length > 3) {
formatted = `${digits.slice(0, 3)}-${digits.slice(3)}`;
}
setPhone(formatted);
};
return (
<div>
<label htmlFor="phone">Phone</label>
<input
id="phone"
type="tel"
value={phone}
onChange={handleChange}
placeholder="123-456-7890"
inputMode="numeric"
/>
</div>
);
}
The noValidate on the form element disables the browser's built-in HTML5 validation, handing full control to React. Without it, the browser shows its own error popups that conflict with the custom inline errors, creating a confusing dual-validation experience.
Tradeoffs
| Feature | Controlled inputs | Uncontrolled inputs |
|---|---|---|
| Real-time validation | Trivial | Requires extra plumbing |
| Input formatting | Trivial | Requires direct DOM manipulation |
| Programmatic reset | Set state | Call form.reset() or clear refs |
| Re-render cost | Per keystroke | None until submit |
| Library support | Formik, most form libs | react-hook-form (default) |
What Interviewers Actually Check
- Whether you know the four-step controlled input cycle
- Whether you use
e.target.namewith a shared handler for multi-field forms - Whether you spread
prevstate when updating one field - Whether you implement real-time validation in
onChange - Whether you disable submit based on validation state
Follow-Up Questions
- How does
react-hook-formdiffer from the manual controlled approach in terms of re-renders and validation strategy? - How would you use
Zodschema validation withreact-hook-formto replace the manual error-setting logic? - How would you debounce the validation so it only runs 300ms after the user stops typing, while keeping the input itself responsive?
- How would you build a multi-step form where each step is a separate component but all steps share the same form state?
- How does React 19's
useFormStatusanduseActionStatechange form handling for Server Actions?
Common Candidate Mistakes
- Setting
valueon an input withoutonChangeand not knowing why the input is read-only - Writing
setForm({ [name]: value })without spreadingprev, which replaces the entire form object and clears all other fields - Only validating on submit, giving users no feedback until they fill the entire form and click submit
- Storing a "formatted" display value and a "raw" value as two separate states and writing
useEffectto sync them, instead of computing the formatted value directly inonChange - Not adding
noValidateto the form element when using custom validation, creating conflicting browser and custom error messages
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you build a multi-field controlled form with a single shared
onChangehandler usinge.target.name? - Can you implement real-time validation that shows errors inline as the user types?
- Can you format input values (phone numbers, credit cards) inside
onChange? - Can you disable the submit button while validation errors exist or the form is incomplete?
- Can you explain why
valuewithoutonChangemakes an input read-only?
Summary
React forms work through controlled inputs. Each form field binds its value to state and calls a state setter in onChange. React is the single source of truth; the DOM reflects state on every render. This makes the current value always available, enabling live validation, dynamic formatting, submit button gating, and dependent field logic.
For multi-field forms, a single shared handler reads e.target.name to identify which field changed and uses a computed property key to update only that field: setForm(prev => ({ ...prev, [name]: value })). Spreading prev is required to preserve the other field values.
Validation runs inside onChange for the best UX: errors appear as the user types rather than only on submit. Input formatting (inserting dashes in phone numbers, grouping digits in credit cards) also runs in onChange: strip to raw digits, apply formatting, and set the formatted value as state. The submit button reads derived state (hasErrors and isComplete) to determine whether to be enabled, so no separate useEffect is needed to sync this state.
Why does setting value without onChange make an input read-only?
React sets the input value from state on every render. Without onChange, the state never updates, so every keypress is immediately overwritten with the same state value, making the input appear frozen. React warns about this in development.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement