React Forms
Forms are how React apps collect input from users — text fields, checkboxes, selects, textareas, and submit buttons. Unlike plain HTML, where the browser’s DOM keeps track of what’s typed into each field, React forms usually put a component’s state in charge of every input’s value. This lesson covers the controlled input pattern in depth: how it works under the hood, how to build multi-field forms, how to validate and submit them, and the mistakes that trip up almost every React beginner.
Overview: Controlled vs. Uncontrolled Inputs
In plain HTML, an <input> manages its own value internally. You read it only when you need it, usually by grabbing the DOM node. React can work this way too — that’s called an uncontrolled input — but the idiomatic React approach is the controlled input, where a piece of component state is the single source of truth for what the field displays.
A controlled input sets its value prop from state and updates that state on every onChange event. This means the input never actually manages its own value — React does, by re-rendering the element with whatever the state currently holds. If you set value but never update the backing state in onChange, the field will appear frozen: the user can type, but the DOM node gets rendered right back to the old value on every re-render.
Why bother with this indirection? Because it makes the form’s data flow explicit and predictable. The component’s state always reflects exactly what’s on screen, so you can validate on every keystroke, transform input as it’s typed (uppercase it, strip characters), conditionally disable the submit button, or reset the whole form by simply resetting state — no manual DOM reads required. Multiple pieces of UI (a live character counter, a preview panel) can all derive from the same state without ever touching the DOM directly.
Uncontrolled inputs still have a place: simple forms where you only care about the value at submit time, integrating with non-React code, or <input type="file">, whose value browsers refuse to let JavaScript set for security reasons (so file inputs are always uncontrolled). For an uncontrolled field you use defaultValue instead of value, and read the current value through a ref when you actually need it:
import { useRef } from "react";
function UncontrolledName() {
const nameRef = useRef(null);
function handleSubmit(e) {
e.preventDefault();
console.log("Name was:", nameRef.current.value);
}
return (
<form onSubmit={handleSubmit}>
<input type="text" defaultValue="" ref={nameRef} />
<button type="submit">Submit</button>
</form>
);
}
This lesson focuses mainly on controlled forms, since that’s what you’ll use for the vast majority of real React forms — anywhere you need validation, conditional UI, or a value that other parts of the component depend on.
Syntax
const [value, setValue] = useState("");
<input
type="text"
name="fieldName"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
The pieces of a controlled field:
| Part | Purpose |
|---|---|
value |
Ties the input’s displayed content to a piece of state. React re-renders the DOM node to always match this. |
onChange |
Fires on every keystroke (or selection change). Reads the new value from e.target.value and updates state. |
name |
Lets one shared handler know which field changed, useful when several inputs update the same state object. |
e.target |
The underlying DOM node from the React SyntheticEvent; has .value, and for checkboxes, .checked. |
Different form elements expose their value through different props. Use this table as a quick reference:
| Element | Controlled via |
|---|---|
<input type="text"> |
value + onChange |
<input type="checkbox"> |
checked + onChange |
<input type="radio"> |
checked + onChange |
<textarea> |
value + onChange (not children, unlike plain HTML) |
<select> |
value + onChange on the <select>, not on <option> |
Examples
Example 1: A Single Controlled Input
import { useState } from "react";
function NameInput() {
const [name, setName] = useState("");
return (
<div>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter your name"
/>
<p>Hello, {name || "stranger"}!</p>
</div>
);
}
export default NameInput;
Renders a text box and a greeting paragraph below it. As the user types, name updates on every keystroke, so the greeting updates live, character by character, with no separate “submit” step.
Example 2: A Multi-Field Form with One State Object
import { useState } from "react";
function SignupForm() {
const [formData, setFormData] = useState({
username: "",
email: "",
password: "",
});
function handleChange(e) {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
}
function handleSubmit(e) {
e.preventDefault();
console.log("Submitting:", formData);
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="username">Username</label>
<input
id="username"
name="username"
type="text"
value={formData.username}
onChange={handleChange}
/>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
/>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
value={formData.password}
onChange={handleChange}
/>
<button type="submit">Sign Up</button>
</form>
);
}
export default SignupForm;
Renders three labeled inputs and a submit button. Instead of one useState per field, this form keeps a single formData object and one shared handleChange function. The name attribute on each input tells the handler which key to update, using a computed property name ([name]: value) inside a spread copy of the previous state. Submitting the form calls e.preventDefault() to stop the browser’s default full-page reload, then logs the current data.
Output (in the browser console after typing “ada”, “ada@example.com”, “secret123” and clicking Sign Up):
Submitting: { username: "ada", email: "ada@example.com", password: "secret123" }
Example 3: Select, Checkbox, Textarea, and Validation
import { useState } from "react";
function FeedbackForm() {
const [feedback, setFeedback] = useState({
topic: "general",
message: "",
subscribe: false,
});
const [error, setError] = useState("");
function handleChange(e) {
const { name, value, type, checked } = e.target;
setFeedback((prev) => ({
...prev,
[name]: type === "checkbox" ? checked : value,
}));
}
function handleSubmit(e) {
e.preventDefault();
if (feedback.message.trim().length < 10) {
setError("Message must be at least 10 characters.");
return;
}
setError("");
console.log("Feedback submitted:", feedback);
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="topic">Topic</label>
<select id="topic" name="topic" value={feedback.topic} onChange={handleChange}>
<option value="general">General</option>
<option value="bug">Bug Report</option>
<option value="feature">Feature Request</option>
</select>
<label htmlFor="message">Message</label>
<textarea
id="message"
name="message"
value={feedback.message}
onChange={handleChange}
rows={4}
/>
<label htmlFor="subscribe">
<input
id="subscribe"
name="subscribe"
type="checkbox"
checked={feedback.subscribe}
onChange={handleChange}
/>
Subscribe to updates
</label>
{error && <p>{error}</p>}
<button type="submit">Send Feedback</button>
</form>
);
}
export default FeedbackForm;
Renders a dropdown, a multi-line textarea, a checkbox, an optional error message, and a submit button. The shared handleChange now branches on type: checkboxes report their state through e.target.checked instead of e.target.value, so the handler picks the right property. On submit, a short validation check rejects a message under 10 characters and shows an inline error instead of logging the data; a valid submission clears the error and logs the result.
Under the Hood: What Happens on Each Keystroke and Submit
It helps to trace the full cycle for a controlled input:
- Mount: the component runs once,
useStatecreates the initial value (for example, an empty string), and React renders the input with that value baked into the DOM node’svalueattribute. - Keystroke: the browser fires a native
inputevent. React’s synthetic event system wraps it and calls youronChangehandler with aSyntheticEventwhosetargetis the real DOM node. - State update: your handler calls
setState(or the object-updater form,setFormData(prev => ({ ...prev, [name]: value }))). This schedules a re-render; it does not update the DOM immediately or synchronously mutate anything. - Render: React re-invokes the function component. The JSX is re-evaluated with the new state, producing a new virtual DOM tree where the input’s
valueprop reflects the just-typed character. - Reconciliation and commit: React diffs the new tree against the previous one. Since the input element itself hasn’t changed type or position, React reuses the same DOM node and simply updates its
valueattribute to match the new state. Visually this feels instantaneous, but it is always render-then-commit, never a direct DOM write from your handler. - Submit: the browser’s default form behavior is to navigate to the form’s
actionURL, which would reload the page and lose all component state. Callinge.preventDefault()insideonSubmitstops that, letting you handle the data entirely in JavaScript — by nowstatealready holds the final values, since every field kept it in sync as the user typed.
Common Mistakes
Mistake 1: Setting value Without onChange
// Wrong: input becomes unresponsive to typing
const [name, setName] = useState("");
<input type="text" value={name} />
Because React re-renders the input with value={name} every time, and nothing ever updates name, every keystroke gets immediately overwritten back to the empty string. The field appears frozen, and React logs a console warning about providing a value without an onChange handler. Fix it by wiring up onChange:
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
Mistake 2: Mutating State Directly
// Wrong: mutates the existing object instead of creating a new one
function handleChange(e) {
formData[e.target.name] = e.target.value;
setFormData(formData);
}
This mutates the same object reference that’s already in state, then calls setFormData with that same reference. React compares state by reference for some optimizations and, more importantly, other parts of your app that read the old state may already hold onto that object — you’ve silently changed data out from under them, and in some cases React may not even re-render because the reference didn’t change. Always build a new object:
function handleChange(e) {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
}
Mistake 3: Letting an Input Flip from Uncontrolled to Controlled
// Wrong: state starts as undefined
const [email, setEmail] = useState();
<input type="text" value={email} onChange={(e) => setEmail(e.target.value)} />
On the first render, email is undefined, so React treats the input as uncontrolled (no value at all). The moment the user types, email becomes a string and the input suddenly becomes controlled. React warns: “A component is changing an uncontrolled input to be controlled.” Always initialize form state with a concrete value of the right type, typically an empty string:
const [email, setEmail] = useState("");
Best Practices
- Group related fields into a single state object and use a shared
handleChangekeyed off each input’snameattribute, instead of oneuseStatecall per field. - Always initialize state to the correct type (
""for text,falsefor checkboxes,[]for multi-selects) so an input never flips between uncontrolled and controlled. - Call
e.preventDefault()in everyonSubmithandler unless you genuinely want the browser’s native form submission. - Validate on submit for most fields, and consider validating on
onBlurfor expensive checks; validating on every keystroke can feel naggy for fields like email or password. - Pair every input with a
<label htmlFor="...">and matchingidfor accessibility and to make the label clickable. - Disable the submit button (or show a spinner) while an async submission is in flight to prevent duplicate submits.
- For large, complex forms with many validation rules, consider a dedicated library like React Hook Form or Formik instead of hand-rolling everything — but understand the controlled-input pattern first, since those libraries build on it.
- Never mutate state objects or arrays in place; always create new ones with spread syntax so React can detect the change and other references stay untouched.
Practice Exercises
- Build a controlled login form with
emailandpasswordfields backed by a single state object. On submit, log the values and clear the password field only (leave the email filled in). - Extend the
FeedbackFormexample so thetopicselect also requires a non-default choice — show an error if the user submits whiletopicis still"general"and a checkbox labeled “I confirm this is accurate” is unchecked. - Build a form with a list of interest checkboxes (e.g. “Sports”, “Music”, “Tech”) where checked values are stored in a state array. Add code to correctly add or remove an item from that array without mutating it directly when a checkbox is toggled.
Summary
- Controlled inputs tie an element’s
value(orchecked) to component state and update that state viaonChange, making state the single source of truth for what’s on screen. - Uncontrolled inputs use
defaultValueand arefinstead, useful for simple cases or elements like<input type="file">that can’t be controlled. - A shared
handleChangekeyed by each input’snameattribute keeps multi-field forms concise; always update state immutably with spread syntax. - Checkboxes read
e.target.checked, while text inputs, selects, and textareas reade.target.value. - Always call
e.preventDefault()inonSubmitto stop the browser’s default page reload. - Common bugs include a
valuewithoutonChange(frozen input), direct state mutation, and initializing state asundefined(uncontrolled-to-controlled warning).
