Controlled Forms in React
A complete guide to controlled forms in React — the value/onChange pattern, handling many fields with one handler, submit handling, basic validation, and textarea and select.
What you'll learn
- ✓The difference between controlled and uncontrolled inputs
- ✓The value + onChange pattern that powers every controlled form
- ✓How to manage many fields with a single state object and one handler
- ✓How to handle submit, including preventDefault and disabling during submit
- ✓Basic synchronous validation, error display, and required fields
- ✓How textarea, select, and checkbox differ from text inputs
Prerequisites
- •You are comfortable with useState — see useState and useEffect
- •Comfort with HTML form elements and event objects
Forms are where React’s “state lives in JavaScript” model has the most visible payoff — and the most boilerplate. This post covers the patterns you need to write any form: a single text input, a long form with many fields, validation, submit handling, and the input types that behave a little differently. By the end you will have a template you can drop into any project.
Controlled vs. uncontrolled
In an HTML form, the input element owns its own value. You type, the input’s value attribute updates, and you read it back when the user submits.
In a controlled React form, your component owns the value. The input displays what state says it should display, and the input tells the component about changes via onChange.
import { useState } from "react";
function NameField() {
const [name, setName] = useState("");
return (
<input
value={name}
onChange={(e) => setName(e.target.value)}
/>
);
}
Three things make this controlled:
value={name}— the input shows whatever state says.onChange={...}— every keystroke updates state.- State is the single source of truth — the DOM does not hold a separate value.
The alternative — uncontrolled — uses a ref to read the input’s DOM value only when needed:
import { useRef } from "react";
function NameFieldUncontrolled() {
const ref = useRef(null);
function handleSubmit() {
console.log(ref.current.value);
}
return (
<>
<input ref={ref} defaultValue="" />
<button onClick={handleSubmit}>Submit</button>
</>
);
}
Uncontrolled forms are shorter and sometimes faster. They are the right tool when you only need the value at submit time and never want to react to changes (no live validation, no conditional fields, no character counter). For everything else, controlled is the default.
The rest of this post uses controlled inputs.
A single field, end to end
The minimal controlled form:
import { useState } from "react";
function SearchBox() {
const [query, setQuery] = useState("");
function handleSubmit(e) {
e.preventDefault();
console.log("Searching for:", query);
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="q">Search</label>
<input
id="q"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button type="submit">Go</button>
</form>
);
}
Two non-obvious details.
e.preventDefault()stops the browser from doing a full-page submit. Without it, the page reloads and your handler never gets to do anything useful.- The submit handler lives on the
<form>, not the button. This way the form also submits when the user presses Enter inside the input.
The value is now reactive. Anywhere else in the component you can read query, derive a count, render suggestions — all without touching the DOM.
Many fields with one handler
A long form with one useState per field works, but the boilerplate grows quickly. The common pattern is a single state object and one shared handler, keyed by the input’s name.
import { useState } from "react";
function SignUpForm() {
const [form, setForm] = useState({
firstName: "",
lastName: "",
email: "",
role: "viewer",
});
function handleChange(e) {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
}
function handleSubmit(e) {
e.preventDefault();
console.log("Submitting", form);
}
return (
<form onSubmit={handleSubmit}>
<input
name="firstName"
value={form.firstName}
onChange={handleChange}
placeholder="First name"
/>
<input
name="lastName"
value={form.lastName}
onChange={handleChange}
placeholder="Last name"
/>
<input
name="email"
type="email"
value={form.email}
onChange={handleChange}
placeholder="Email"
/>
<button type="submit">Create account</button>
</form>
);
}
The mechanics:
- Each input has a
namematching a key in the state object. handleChangereadse.target.nameande.target.value, then merges them into state with the computed property name[name]: value.- The functional updater
(prev) => ({ ...prev, [name]: value })is important. Inputs fire fast and state updates can batch — without the functional form you can lose keystrokes if two updates land in the same batch.
Add a new field by adding one key to state and one input. No new handler.
Try it yourself. Build the SignUpForm above and add a phone field by changing only two places — the initial state object and the JSX. Confirm the existing handler works without modification.
Submitting and disabling during submit
Real submits go somewhere — usually fetch. You should disable the form while it is in flight so the user cannot double-click and create two accounts.
const [form, setForm] = useState({ name: "", email: "" });
const [submitting, setSubmitting] = useState(false);
const [serverError, setServerError] = useState(null);
async function handleSubmit(e) {
e.preventDefault();
setSubmitting(true);
setServerError(null);
try {
const res = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (!res.ok) throw new Error(`Server returned ${res.status}`);
// ...success, redirect or reset form
} catch (err) {
setServerError(err.message);
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit}>
{/* ...inputs... */}
<button type="submit" disabled={submitting}>
{submitting ? "Saving…" : "Save"}
</button>
{serverError && <p className="error">{serverError}</p>}
</form>
);
disabled={submitting} on the submit button is the minimum. Disabling individual inputs is optional but feels nicer for slow networks.
Basic validation
Synchronous validation lives in plain JavaScript. The standard shape: a function that takes the form state and returns an object of error messages.
function validate(form) {
const errors = {};
if (!form.email.includes("@")) errors.email = "Enter a valid email.";
if (form.password.length < 8) errors.password = "At least 8 characters.";
if (form.password !== form.confirm) errors.confirm = "Passwords don't match.";
return errors;
}
You can call validate on every change for live feedback, only on submit for less noise, or — most commonly — when the user blurs a field for the first time.
A simple submit-time pattern:
const [errors, setErrors] = useState({});
function handleSubmit(e) {
e.preventDefault();
const next = validate(form);
setErrors(next);
if (Object.keys(next).length > 0) return;
// ...send to server
}
return (
<form onSubmit={handleSubmit}>
<input name="email" value={form.email} onChange={handleChange} />
{errors.email && <p className="error">{errors.email}</p>}
{/* ...etc */}
</form>
);
For richer validation — async checks, dependent fields, complex schemas — libraries like react-hook-form and zod save a lot of boilerplate. The pattern above is enough for most internal tools and small public forms.
Required, type, and pattern
The HTML attributes still work and give you free baseline validation in modern browsers:
<input
name="email"
type="email"
required
value={form.email}
onChange={handleChange}
/>
type="email" triggers a basic regex check; required blocks empty submits; pattern="..." lets you enforce a custom regex. You will still want JavaScript validation for anything substantive — error messages, live feedback, server checks — but the HTML hooks are cheap insurance.
Textarea, select, and checkbox
These three input types behave slightly differently in HTML. React smooths over the difference so the controlled pattern stays the same.
Textarea
In HTML, a textarea has its value between its tags. In React, you use the value prop:
<textarea
name="bio"
value={form.bio}
onChange={handleChange}
rows={4}
/>
Everything else — name, onChange, validation — works identically to a text input.
Select
In HTML, the selected option carries selected. In React, the <select> itself carries value:
<select name="role" value={form.role} onChange={handleChange}>
<option value="admin">Admin</option>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</select>
For a multi-select, pass an array as value and add multiple. The e.target.value becomes more complicated then — read Array.from(e.target.selectedOptions, (o) => o.value).
Checkbox
Checkboxes do not have a meaningful value attribute. They have checked. So the controlled pattern uses checked and reads from e.target.checked:
<label>
<input
name="newsletter"
type="checkbox"
checked={form.newsletter}
onChange={(e) =>
setForm((prev) => ({ ...prev, newsletter: e.target.checked }))
}
/>
Email me the newsletter
</label>
If you want one handler that covers both text and checkbox inputs, branch on type:
function handleChange(e) {
const { name, type, value, checked } = e.target;
setForm((prev) => ({
...prev,
[name]: type === "checkbox" ? checked : value,
}));
}
Now the same handleChange works across the whole form.
Try it yourself. Build a small contact form with a text input, a textarea, a select, and a checkbox. Use the single-handler pattern with the type === "checkbox" branch. Log the state object on submit and confirm every field is correctly typed (booleans for checkboxes, strings for the rest).
A small worked example
A new-post form with validation, submit handling, and every input type.
import { useState } from "react";
const empty = { title: "", body: "", category: "general", draft: false };
function validate(f) {
const errors = {};
if (f.title.trim().length < 3) errors.title = "Title is too short.";
if (f.body.trim().length === 0) errors.body = "Body is required.";
return errors;
}
function NewPostForm() {
const [form, setForm] = useState(empty);
const [errors, setErrors] = useState({});
const [submitting, setSubmitting] = useState(false);
function handleChange(e) {
const { name, type, value, checked } = e.target;
setForm((prev) => ({
...prev,
[name]: type === "checkbox" ? checked : value,
}));
}
async function handleSubmit(e) {
e.preventDefault();
const next = validate(form);
setErrors(next);
if (Object.keys(next).length > 0) return;
setSubmitting(true);
try {
await fetch("/api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
setForm(empty);
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit}>
<label>
Title
<input name="title" value={form.title} onChange={handleChange} />
</label>
{errors.title && <p className="error">{errors.title}</p>}
<label>
Body
<textarea
name="body"
value={form.body}
onChange={handleChange}
rows={6}
/>
</label>
{errors.body && <p className="error">{errors.body}</p>}
<label>
Category
<select name="category" value={form.category} onChange={handleChange}>
<option value="general">General</option>
<option value="react">React</option>
<option value="typescript">TypeScript</option>
</select>
</label>
<label>
<input
name="draft"
type="checkbox"
checked={form.draft}
onChange={handleChange}
/>
Save as draft
</label>
<button type="submit" disabled={submitting}>
{submitting ? "Publishing…" : "Publish"}
</button>
</form>
);
}
export default NewPostForm;
Single state object, one handler, validation on submit, submit-time disabling, and every input type controlled the same way.
Recap
You now know:
- A controlled input pairs
valuewithonChangeand treats state as the single source of truth - For many fields, use one state object keyed by input
nameand one handler that uses[name]: value - Always call
e.preventDefault()in your submit handler - Disable the submit button while the request is in flight to prevent double-submits
- Validation is plain JavaScript — return an
errorsobject and render messages conditionally <textarea>usesvalue(not children);<select>putsvalueon the select itself; checkboxes usechecked- One handler can cover both text and checkbox inputs by branching on
e.target.type
Next steps
That covers the React side of forms. Most production forms also need stronger type safety — what shape is the form data, what does the API expect, how do you keep them in sync? The next series shifts to TypeScript. Start with the building blocks of the type system and how to describe API payloads precisely.
→ Next: TypeScript Enums and Literal Types
Questions or feedback? Email codeloomdevv@gmail.com.