Module 5: Forms, Styling and Routing
Forms: Controlled and Uncontrolled Inputs
The controlled-input loop, handling many fields at once, uncontrolled inputs with FormData, and React 19's form actions.
Forms are where React's one-way data flow meets the browser's habit of letting inputs keep their own state. Both ideas have a place. Knowing which to use, and why, removes most of the confusion people have with forms.
Controlled inputs#
In a controlled input, React state is the single source of truth. The input displays whatever the state says, and every change goes through state.
function NameForm() {
const [name, setName] = useState("");
return (
<input
value={name}
onChange={(e) => setName(e.target.value)}
/>
);
}
user types "a"
│
▼
onChange fires ──► setName("a")
│
▼
React re-renders, input value = "a"
│
▼
DOM input shows "a"
That round trip happens on every keystroke. It's fast enough that you won't notice, and it gives you total control. You can transform the text as it's typed, block invalid characters, validate live, or reset the field from anywhere.
If you set value without an onChange, the input becomes read-only. React warns about it and nothing you type appears. The state never changes, so the displayed value never changes. That's the controlled loop working exactly as designed.
Handling many fields#
Don't create a useState and a handler per field. Use one object and the input's name attribute.
function SignupForm() {
const [form, setForm] = useState({
name: "",
email: "",
plan: "free",
newsletter: false,
});
function handleChange(e) {
const { name, value, type, checked } = e.target;
setForm((prev) => ({
...prev,
[name]: type === "checkbox" ? checked : value,
}));
}
function handleSubmit(e) {
e.preventDefault();
console.log(form);
}
return (
<form onSubmit={handleSubmit}>
<label>
Name
<input name="name" value={form.name} onChange={handleChange} />
</label>
<label>
Email
<input name="email" type="email" value={form.email} onChange={handleChange} />
</label>
<label>
Plan
<select name="plan" value={form.plan} onChange={handleChange}>
<option value="free">Free</option>
<option value="pro">Pro</option>
</select>
</label>
<label>
<input name="newsletter" type="checkbox" checked={form.newsletter} onChange={handleChange} />
Send me the newsletter
</label>
<button type="submit">Create account</button>
</form>
);
}
The [name]: value syntax is a computed property key. It uses whatever string is in name as the key. Notice two quirks. Checkboxes use checked instead of value. And select and textarea in React take a value prop, unlike HTML where selection lives on child option tags or text sits between the textarea tags.
Always wrap inputs in label (or use htmlFor). It makes the click target bigger and it's what screen readers announce.
Uncontrolled inputs#
Sometimes you don't need to know the value on every keystroke, just at submit time. Then let the DOM hold the value, and read it when needed.
function ContactForm() {
function handleSubmit(e) {
e.preventDefault();
const data = new FormData(e.currentTarget);
const values = Object.fromEntries(data); // { email: "...", message: "..." }
console.log(values);
}
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" defaultValue="" required />
<textarea name="message" defaultValue="" />
<button>Send</button>
</form>
);
}
You use defaultValue (not value) to set an initial value without taking control. This version has zero state and zero re-renders while typing. For big forms, that's a real saving, and it's the approach React Hook Form is built on.
Another way to read an uncontrolled input is a ref (inputRef.current.value), which is handy for one-off cases like focusing or reading a file input.
A file input is always uncontrolled, because only the user can set its value for security reasons:
<input type="file" onChange={(e) => setFile(e.target.files[0])} />
Which should you use#
Use controlled when:
- you validate or format as the user types
- other UI depends on the current value (a live preview, a disabled button)
- you need to set the value programmatically (reset, prefill from an API)
Use uncontrolled when:
- you only need the values on submit
- the form is large and typing feels laggy
- you're using a form library that manages it
Don't mix them for the same field. Switching an input from uncontrolled (value={undefined}) to controlled (value="x") triggers a console warning and odd behavior. Initialize controlled fields with "", never undefined or null.
Form actions in React 19#
React 19 lets you pass a function to a form's action prop. React calls it with the FormData when the form is submitted, and resets uncontrolled fields afterward.
function Feedback() {
async function send(formData) {
const message = formData.get("message");
await fetch("/api/feedback", { method: "POST", body: JSON.stringify({ message }) });
}
return (
<form action={send}>
<textarea name="message" />
<button>Send</button>
</form>
);
}
There's no preventDefault and no state. To show pending status and results, use the companion hooks:
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
function SubmitButton() {
const { pending } = useFormStatus(); // must be used inside the form
return <button disabled={pending}>{pending ? "Sending..." : "Send"}</button>;
}
function Feedback() {
const [message, formAction] = useActionState(async (prev, formData) => {
const text = formData.get("message");
if (!text) return "Write something first.";
await fetch("/api/feedback", { method: "POST", body: text });
return "Thanks!";
}, "");
return (
<form action={formAction}>
<textarea name="message" />
<SubmitButton />
<p>{message}</p>
</form>
);
}
For a plain client-side app, either style works. The onSubmit plus state approach is still extremely common, and you'll need to be comfortable with it.
Where people get burned#
- Setting
valuewithoutonChange, then wondering why the field is frozen. - Initializing a controlled field with
undefinedornull, so the "uncontrolled to controlled" warning fires. - Using
valueon a checkbox instead ofchecked. - Forgetting
e.preventDefault()and watching the page reload on submit. - Storing a number input's value as a number without converting.
e.target.valueis always a string. - Making every field controlled in a 60-field form and fighting performance.
Practice#
- Build the
SignupFormabove, and add a live preview under it showing"Hi {name}, you chose the {plan} plan". - Make a button that resets the form to its initial values. Which approach (controlled or uncontrolled) makes this easier, and why?
- Build a contact form with
FormDataand no state. Log the data on submit. - Add a number field for age, convert it with
Number, and show an error if it's under 18.