In React, form inputs work best when your component's state ā not the DOM ā is the single source of truth for what's displayed. This lesson covers the controlled component pattern: wiring value and onChange together, managing multi-field forms with object state, and handling submission and validation correctly.
1The Single Source of Truth
In traditional HTML, input fields maintain their own internal state ā the DOM node itself remembers what text is inside it. React prefers the component's state to be the single source of truth instead, taking control of the input so that what's displayed is dictated entirely by a state variable rather than by the browser's own tracking.
This is what makes a form input a 'controlled component': React reads the current value from state and renders it, rather than letting the input manage its own text independently.
// Controlled Forms: Data flow in inputsReact Component Preview
2The Control Paradigm
A controlled component relies on two paired props: value, which reads from state and tells the input exactly what to display, and onChange, an event listener that fires on every keystroke so the state can be updated in response. Both are required together.
If you provide only a value prop without a matching onChange, the input becomes completely locked ā the displayed text can never change, because nothing is updating the state it's reading from.
const [name, setName] = useState('');
return <input value={name} onChange={...} />;React Component Preview
3Capturing Input Data
When onChange fires, it receives a Synthetic Event object, conventionally named e. The text the user just typed lives inside e.target.value, so you read that property and pass it directly into your state setter, such as setName(e.target.value).
That setter call triggers a re-render, and because the input's value prop reads from the same state, the input visually updates with the new character almost instantly.
<input
value={name}
onChange={(e) => setName(e.target.value)}
/>React Component Preview
4Intercepting Input
Because React sits between the user's keyboard and what actually renders in the input, you have full control over what gets displayed. You can format e.target.value.toUpperCase() before saving it to state to force uppercase text, or run a regex check and simply skip the state update to block characters like numbers entirely.
This interception happens on every keystroke, so formatting or restrictions feel instantaneous to the user rather than applied after the fact.
onChange={(e) => setName(e.target.value.toUpperCase())}React Component Preview
5Objects for State
Creating a separate useState hook for every field in a form with ten inputs is tedious. The better approach is a single object state, const [form, setForm] = useState({ name: '', email: '' }), that groups all related form data together for easy access during submission.
To update just one field without wiping out the rest, you write a single generic handleChange function that relies on each input's name attribute matching a key in the object, combined with the ES6 computed property syntax { ...form, [e.target.name]: e.target.value } to update only the field that changed.
const [form, setForm] = useState({ user: '', email: '' });
const handleChange = (e) => {
setForm({ ...form, [e.target.name]: e.target.value });
};React Component Preview
6Handling Submission
When a user hits Enter or clicks submit inside a <form>, the browser's default behavior is to fire an HTTP request and reload the entire page ā which is catastrophic in a React Single Page Application, since a reload wipes out all of your JavaScript state.
You must always capture the onSubmit event on the <form> element and call e.preventDefault() first, before running any of your own submission logic like sending the form data to an API.
const handleSubmit = (e) => {
e.preventDefault();
console.log('Submitted:', form);
};
return <form onSubmit={handleSubmit}>...</form>;React Component Preview
7Selects and Textareas
In standard HTML, <textarea> uses inner text as its value and <select> relies on complex option structures, but React unifies both of these. Inside React, <textarea> and <select> behave exactly like text inputs ā they accept the same value prop and onChange event.
That means you don't need to learn a separate API for each form element type; the controlled-component pattern you already know for text inputs applies identically here.
<select value={choice} onChange={e => setChoice(e.target.value)}>
<option value='A'>Alpha</option>
</select>8Checkbox Exceptions
Checkboxes and radio buttons are the one exception to the value rule, because they represent a boolean true/false state rather than text. They're bound with the checked prop instead of value, and the user's interaction is read from e.target.checked instead of e.target.value.
Forgetting this distinction ā for example, trying to bind a checkbox with value ā leaves React unable to properly reflect the checkbox's on/off state.
<input
type='checkbox'
checked={isAdmin}
onChange={e => setIsAdmin(e.target.checked)}
/>React Component Preview
9Putting it Together
With value/onChange on text fields, checked/onChange on checkboxes, and a single object state driving a multi-field form, every input in a registration form stays perfectly synchronized with component state at all times.
Because state is always current, the UI can react instantly to what's been typed or checked ā greying out a submit button, showing a live validation message, or reformatting text ā all without a single manual DOM query.
/* Form Lab: Multi-Step Registration Rendered */React Component Preview
10State-Driven Validation
Because component state already holds every field's current value in real time, validation reduces to simple boolean expressions checked directly against that state ā no manual DOM queries or length checks required. A rule like const isValid = password.length >= 8 is enough to know whether a field currently passes.
That boolean can be passed straight into a submit button's disabled prop, such as <button disabled={!form.email.includes('@')}>Submit</button>, preventing invalid data from ever leaving the component in the first place.
<button disabled={!form.email.includes('@')}>Submit</button>React Component Preview
11Step-by-Step Breakdown
Controlled Forms. Welcome to Controlled Forms. In traditional HTML, input elements like <input>, <textarea>, and <select> maintain their own internal state and update based on user typing. In React, we prefer the component's state to be the 'Single Source of Truth'. We take control of the input, completely linking what it displays to a React state variable.
The value and onChange Pair. A 'Controlled Component' requires two specific props: value and onChange. The value prop tells the input exactly what text to display on the screen, directly reading from your state. The onChange prop is an event listener that fires every time the user types a keystroke, allowing you to update the state. Without onChange, a controlled input is read-only and literally impossible to type in!
The Event Payload. When onChange fires, it receives a Synthetic Event object, usually named e. Deep inside this object is the exact text the user just typed. You access it via e.target.value. You take this value and pass it directly into your state setter function (setName). This triggers a re-render, and the input visually updates with the new letter almost instantly.
Which property on the event object 'e' contains the current text payload the user just typed into the input?
- āe.current.value
- āe.target.value
Real-Time Formatting. Because React sits between the user's keyboard and the input display, you have absolute power over what actually gets rendered. If you want a field to only accept uppercase letters, you can format e.target.value.toUpperCase() BEFORE saving it to state. If you want to block numbers, you can run a regex check and simply ignore the keystroke if it fails.
Multi-Field Object State. If you have a form with 10 inputs, creating 10 different useState hooks (setName, setEmail, setPhone, etc.) is tedious. The best practice is to use a single state Object to hold all your form data. const [form, setForm] = useState({ name: '', email: '' }). This groups related data together perfectly for submission.
Computed Property Names. When using an object for state, you can write a single, universal handleChange function for all your inputs. You do this by giving each <input> a name attribute that perfectly matches a key in your state object. Then, in the handler, you use ES6 Computed Property Names [e.target.name]: e.target.value to dynamically update exactly the field the user typed in.
When updating an object state, why must we use the spread operator (...form) before providing the new field value?
- āTo preserve existing fields; React doesn't auto-merge objects in hooks
- āTo improve rendering speed of the input
Form Submission. When a user hits 'Enter' or clicks a submit button inside a <form>, the browser's default behavior is to execute an HTTP request and reload the entire page! In React Single Page Applications (SPAs), page reloads are catastrophic because they wipe out your JavaScript state. You must ALWAYS capture the onSubmit event and call e.preventDefault().
Which function MUST you call inside your form's onSubmit handler to prevent the browser from reloading the page and destroying your React state?
- āe.stopPropagation()
- āe.preventDefault()
Selects and Textareas. In standard HTML, <textarea> uses inner text for its value, and <select> uses complex option structures. React unifies this! In React, both <textarea> and <select> operate exactly like text inputs: they accept a value prop and an onChange event. You don't have to learn different APIs for different form elements.
Checkboxes. The one exception to the 'value' rule is the Checkbox (and Radio button). Checkboxes represent a boolean state (true/false) rather than text. Therefore, they are bound using the checked prop instead of value, and you extract the user's interaction from e.target.checked instead of e.target.value.
Which specific attribute on a <input type='checkbox'> element must be linked to your React state to make it a controlled component?
- āvalue
- āchecked
Form Validation. Because your component state holds all the answers in real-time, validation is trivial. You can write simple boolean expressions to evaluate if the form is valid. For example, const isValid = password.length >= 8. You can then pass this boolean directly into the disabled prop of your submit button, preventing bad data from ever leaving the component.
Mastery Achieved. Form mastery achieved! You've learned to build rock-solid Controlled Forms. You understand how to sync state to inputs, manage complex objects with computed property names, prevent browser reloads, and validate data in real-time. You are now equipped to build sophisticated authentication flows, settings panels, and data entry dashboards.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported.
Fully supported.
Fully supported.
Fully supported.
Accessibility (A11y)
1Every Controlled Input Needs a Programmatically Associated Label
Binding an input's value to state doesn't make it accessible by itself ā pair every `<input>`, `<select>`, and `<textarea>` with a `<label htmlFor>` (or wrap it in a `<label>`) so screen reader users know what each field is for.
2Disabled Submit Buttons Need an Accessible Reason, Not Just a Visual One
A `disabled={!isValid}` submit button communicates nothing to assistive technology about why it's disabled; surface the actual validation message near the relevant field (e.g., via `aria-describedby`) instead of relying on color or a greyed-out button alone.
SEO Implications
- 1
Forms Themselves Carry Little SEO Weight, But Their Surrounding Content Does
A contact or signup form rendered as a controlled component isn't crawled as meaningful content ā make sure any surrounding copy that explains what the form does is real, server-rendered text rather than something injected only after a client-side interaction.
- 2
Client-Side-Only Validation Errors Shouldn't Block Server-Rendered Content
If a form's success or error state is conditionally rendered based on client state, ensure the initial page load still contains the actual form and its labels in the server-rendered HTML, not just a loading placeholder.
Best Practices
Always Pair value With onChange
A controlled input given a `value` prop but no `onChange` handler becomes permanently read-only ā React will warn about this in development, and it's a strong signal the component is missing its update logic.
Use One Object State With Computed Property Names for Multi-Field Forms
A single `const [form, setForm] = useState({...})` combined with `[e.target.name]: e.target.value` in one shared `handleChange` scales far better than a separate `useState` call per field as a form grows.
Frequent Bugs
Updating one field in an object-based form state wipes out the values of every other field.
The setter was called with only the changed field, like `setForm({ [e.target.name]: e.target.value })`, replacing the entire object. Spread the previous state first: `setForm({ ...form, [e.target.name]: e.target.value })`.
Submitting the form causes the whole page to flash and reload, losing all entered data.
The `<form>`'s `onSubmit` handler is missing `e.preventDefault()`, so the browser's native form submission still fires alongside the React handler. Add `e.preventDefault()` as the first line of the submit handler.
Real-World Examples
A Signup Form With One Shared Change Handler
A signup form with username, email, and password fields uses a single object state and one `handleChange` function shared across all three inputs via matching `name` attributes, keeping the component's logic compact as more fields are added.
const [form, setForm] = useState({ username: '', email: '', password: '' });
const handleChange = (e) => {
setForm({ ...form, [e.target.name]: e.target.value });
};
<input name="email" value={form.email} onChange={handleChange} />