Forms in React · Lesson 14 of 31
React Forms and Controlled Inputs
Sign-ups, checkouts, search filters, settings pages – forms are everywhere. The classic React approach is the controlled input: React state holds the value, and the input always shows that state. It gives you instant validation, formatting and conditional fields with very little code.
In this lesson
- Controlled text inputs and textareas
- Select, checkbox and radio buttons
- One handler for a whole form
- Validation and submission, and when uncontrolled inputs are fine
A controlled input
import { useState } from "react";
export default function App() {
const [phone, setPhone] = useState("");
function handleChange(e) {
const digits = e.target.value.replace(/\D/g, "").slice(0, 10); // keep only 10 digits
setPhone(digits);
}
return (
<div>
<label>Mobile <input value={phone} onChange={handleChange} inputMode="numeric" /></label>
<p>{phone.length === 10 ? "✔ Looks good" : `${10 - phone.length} digits to go`}</p>
</div>
);
}
Mobile [] 10 digits to go › type “98a76-54” into Mobile Mobile [987654] 4 digits to go
The controlled input loop
- Typethe user presses a key
- onChangeyour handler reads e.target.value and may clean it
- setStatethe new value is stored
- Renderthe input shows value={state}
Letters and dashes never appear because the handler strips them before storing. If you set value without an onChange, the field becomes read-only and React warns you.
Select, checkbox and radio
import { useState } from "react";
export default function App() {
const [course, setCourse] = useState("react");
const [newsletter, setNewsletter] = useState(false);
const [plan, setPlan] = useState("free");
return (
<form>
<label>
Course
<select value={course} onChange={(e) => setCourse(e.target.value)}>
<option value="react">React.js</option>
<option value="nextjs">Next.js</option>
<option value="ts">TypeScript</option>
</select>
</label>
<label>
<input type="checkbox" checked={newsletter} onChange={(e) => setNewsletter(e.target.checked)} />
Weekly newsletter
</label>
<fieldset>
<legend>Plan</legend>
{["free", "pro"].map((p) => (
<label key={p}>
<input type="radio" name="plan" value={p} checked={plan === p} onChange={(e) => setPlan(e.target.value)} />
{p}
</label>
))}
</fieldset>
<p>Summary: {course}, {plan} plan, newsletter {newsletter ? "on" : "off"}</p>
</form>
);
}
Course [React.js ▾] [ ] Weekly newsletter Plan (•) free ( ) pro Summary: react, free plan, newsletter off › choose “Next.js” in Course Course [Next.js ▾] [ ] Weekly newsletter Plan (•) free ( ) pro Summary: nextjs, free plan, newsletter off › tick Weekly newsletter Course [Next.js ▾] [x] Weekly newsletter Plan (•) free ( ) pro Summary: nextjs, free plan, newsletter on › click “pro” Course [Next.js ▾] [x] Weekly newsletter Plan ( ) free (•) pro Summary: nextjs, pro plan, newsletter on
| Element | Controlled by | Read from |
|---|---|---|
<input>, <textarea> | value | e.target.value |
<select> | value on the select (not selected on options) | e.target.value |
| checkbox | checked | e.target.checked |
| radio group | checked={state === value} | e.target.value |
One handler for the whole form
For forms with many fields, keep one state object and use each input's name:
import { useState } from "react";
const empty = { name: "", email: "", password: "", terms: false };
function validate(v) {
const errors = {};
if (v.name.trim().length < 2) errors.name = "Enter your full name";
if (!/^\S+@\S+\.\S+$/.test(v.email)) errors.email = "Enter a valid email";
if (v.password.length < 8) errors.password = "Use at least 8 characters";
if (!v.terms) errors.terms = "Please accept the terms";
return errors;
}
export default function App() {
const [values, setValues] = useState(empty);
const [submitted, setSubmitted] = useState(false);
const [done, setDone] = useState(null);
const errors = validate(values); // derived on every render
const show = (field) => submitted && errors[field]; // only show after the first submit
function handleChange(e) {
const { name, type, value, checked } = e.target;
setValues((v) => ({ ...v, [name]: type === "checkbox" ? checked : value }));
}
function handleSubmit(e) {
e.preventDefault();
setSubmitted(true);
if (Object.keys(errors).length > 0) return;
setDone(values.name);
setValues(empty);
setSubmitted(false);
}
if (done) return <p>Welcome aboard, {done}! Check your inbox.</p>;
return (
<form onSubmit={handleSubmit} noValidate>
<label>Name <input name="name" value={values.name} onChange={handleChange} /></label>
{show("name") && <p>⚠ {errors.name}</p>}
<label>Email <input name="email" type="email" value={values.email} onChange={handleChange} /></label>
{show("email") && <p>⚠ {errors.email}</p>}
<label>Password <input name="password" type="password" value={values.password} onChange={handleChange} /></label>
{show("password") && <p>⚠ {errors.password}</p>}
<label><input name="terms" type="checkbox" checked={values.terms} onChange={handleChange} /> I accept the terms</label>
{show("terms") && <p>⚠ {errors.terms}</p>}
<button type="submit">Create account</button>
</form>
);
}
Name [] Email [] Password [] [ ] I accept the terms [Create account] › type “Asha Verma” into Name Name [Asha Verma] Email [] Password [] [ ] I accept the terms [Create account] › type “asha@” into Email Name [Asha Verma] Email [asha@] Password [] [ ] I accept the terms [Create account] › click “Create account” Name [Asha Verma] Email [asha@] ⚠ Enter a valid email Password [] ⚠ Use at least 8 characters [ ] I accept the terms ⚠ Please accept the terms [Create account] › type “example.com” into Email Name [Asha Verma] Email [asha@example.com] Password [] ⚠ Use at least 8 characters [ ] I accept the terms ⚠ Please accept the terms [Create account] › type “secret123” into Password Name [Asha Verma] Email [asha@example.com] Password [secret123] [ ] I accept the terms ⚠ Please accept the terms [Create account] › tick I accept the terms Name [Asha Verma] Email [asha@example.com] Password [secret123] [x] I accept the terms [Create account] › click “Create account” Welcome aboard, Asha Verma! Check your inbox.
Client validation is for convenience
Validation in the browser gives fast feedback, but anyone can bypass it. Always validate again on the server. Many teams share one schema (for example with zod) between the client and the server.
Uncontrolled inputs are fine too
If you only need the values on submit, you don't have to track every keystroke. Let the DOM hold the values and read them with FormData:
import { useState } from "react";
export default function App() {
const [result, setResult] = useState(null);
function handleSubmit(e) {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.currentTarget)); // { city: "...", guests: "..." }
setResult(data);
}
return (
<form onSubmit={handleSubmit}>
<label>City <input name="city" defaultValue="Goa" /></label>
<label>Guests <input name="guests" type="number" defaultValue="2" /></label>
<button>Search hotels</button>
{result && <p>Searching {result.city} for {result.guests} guests</p>}
</form>
);
}
City [Goa] Guests [2] [Search hotels] › clear City and type “Jaipur” City [Jaipur] Guests [2] [Search hotels] › click “Search hotels” City [Jaipur] Guests [2] [Search hotels] Searching Jaipur for 2 guests
defaultValue (or defaultChecked) sets the starting value without controlling the input. For large forms, libraries such as React Hook Form use this uncontrolled approach for performance, and add validation on top.
Practice
Try it yourself
Make a textarea for a review with a 60-character limit, a live counter, and a disabled submit button when it's empty.
Show solution
import { useState } from "react"; const MAX = 60; export default function App() { const [review, setReview] = useState(""); return ( <form onSubmit={(e) => e.preventDefault()}> <textarea aria-label="Review" value={review} onChange={(e) => setReview(e.target.value.slice(0, MAX))} /> <p>{review.length}/{MAX}</p> <button disabled={review.trim() === ""}>Post review</button> </form> ); }[] 0/60 [Post review (disabled)] › type “Clear examples, great pace.” into Review [Clear examples, great pace.] 27/60 [Post review]
Quick check
How do you control a checkbox?
With checked, reading e.target.checked in onChange.
What happens if you set value but no onChange?
The input is read-only and React warns you.
How do you give an uncontrolled input a starting value?
With defaultValue (or defaultChecked).
Summary
- Controlled inputs: value/checked from state, updated in onChange.
- One state object plus the input's name handles large forms; derive validation errors.
- Uncontrolled inputs with FormData are fine when you only need values on submit.
Finished this lesson?
Mark it complete to track your progress, then carry on with the next lesson.