Without notes, state yesterday’s main idea and one unresolved question.
Forms and controlled inputs
React Frontend Fundamentals
Objective
Build predictable forms with visible validation.
A React component is a reusable functional block with inputs (props), internal state, and rendered output.
- controlled fields
- submit handling
- field and form errors
Why this matters
Forms are where users put data into your system, and they are where interfaces most often go wrong: values that vanish, errors nobody can see, a submit button that fires twice. Today you build a form whose displayed value and whose validation both come from one place you control.
By the end of the hour you will have an Add Equipment form that refuses incomplete input, explains exactly what is missing in a way a screen reader announces, and clears itself after a successful submit.
Controlled fields
An <input> normally keeps its own value inside the DOM. You type, the DOM remembers, and your
code has to go and ask for it. On Day 26 that is exactly what you did.
A controlled field works the other way round. Its displayed value is driven by React state, and every keystroke reports back so state can change:
const [name, setName] = useState("");
<input
id="name"
value={name} // state decides what shows
onChange={(event) => setName(event.target.value)} // keystroke updates state
/>
Read the loop carefully, because this is the one idea today's quiz turns on. You press p. The
browser fires change. setName("p") stores it and triggers a render. The render returns an input
whose value is "p". React updates the DOM. The letter appears — but it appeared because state
said so, not because the browser put it there.
That indirection buys you real things. State is the single source of truth, so you can pre-fill a field, trim whitespace as it is typed, force uppercase, disable submit while a field is empty, or reset the whole form by assigning one object — all without reading the DOM.
The opposite is an uncontrolled field: you give it defaultValue, let the DOM own the value,
and read it once at submit time. That is fine for simple forms. This week uses controlled fields
because the rest of the app already lives in state.
`value` without `onChange` freezes the field
If you pass value and no onChange, state never changes, so the input redisplays the same
value after every keystroke and appears broken. React tells you in the console: "You provided a
value prop to a form field without an onChange handler. This will render a read-only field."
Either add onChange, or use defaultValue if you meant it to be uncontrolled.
Closed-loop control
An uncontrolled input is an open-loop actuator: it moves and you read its position afterwards.
A controlled input is a closed loop — the keystroke is the feedback signal, React state is the
setpoint, and the rendered value is what the loop drives the display to. The display can never
disagree with the setpoint, because the setpoint is what draws it.
Holding several fields
Three fields could be three useState calls. One object is usually tidier, because reset and
submit then touch a single value:
type FormValues = {
name: string;
location: string;
status: Equipment["status"];
};
const EMPTY: FormValues = { name: "", location: "", status: "ok" };
const [values, setValues] = useState<FormValues>(EMPTY);
Updating one field means building a new object with the spread from Day 59 — never editing the existing one:
setValues({ ...values, name: event.target.value });
Every field then reads values.name, values.location, values.status, and setValues(EMPTY)
resets the entire form in one line.
Submit handling
Put the handler on the <form>, not on the button:
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
// validate, then use the values
}
<form onSubmit={handleSubmit} noValidate>
...
<button type="submit">Add equipment</button>
</form>
Two decisions in there, both about keyboards. Using onSubmit rather than the button's onClick
means pressing Enter in any text field submits the form, which users expect and which
costs you nothing. And type="submit" is what connects the button to the form. A button inside a
form defaults to type="submit", so any other button — "Clear", "Cancel" — must say
type="button" explicitly or it will submit the form by accident.
event.preventDefault() stops the browser's default behaviour of navigating to a new page with the
values in the URL. Without it, your React state is wiped by a full page load. noValidate turns
off the browser's own validation bubbles so that your messages, which you control and can style and
announce, are the only ones shown.
Field errors and form errors
Validation is a plain function. Give it the values, get back a description of what is wrong. Keep it outside the component so it is easy to read and, later, easy to test:
type Errors = Partial<Record<keyof FormValues, string>>;
function validate(values: FormValues): Errors {
const errors: Errors = {};
if (values.name.trim() === "") errors.name = "Name is required.";
if (values.location.trim() === "") errors.location = "Location is required.";
return errors;
}
keyof FormValues is the union "name" | "location" | "status", and Partial makes every key
optional — so Errors is "a message for some fields, maybe none". Week 5 generics, doing real work.
There are two levels of error and they need different treatment:
- A field error belongs next to its input: "Name is required."
- A form error is about the submission as a whole: "Could not save. Try again." It belongs at the top of the form, near the submit button.
Showing them accessibly takes three attributes:
<label htmlFor="name">Name</label>
<input
id="name"
value={values.name}
onChange={(event) => setValues({ ...values, name: event.target.value })}
aria-invalid={errors.name ? true : undefined}
aria-describedby={errors.name ? "name-error" : undefined}
/>
{errors.name && (
<p id="name-error" className="field-error">{errors.name}</p>
)}
htmlFor ties the label to the input, so clicking the label focuses the field and a screen reader
announces the name. aria-invalid marks the field as failing. aria-describedby points at the id
of the message, so the error is read out with the field rather than sitting in the page as
unattached red text. Colour alone is never enough — the message must be words.
For the form-level error, give the container role="alert". When an element with that role appears,
assistive technology announces it, which is what a sighted user gets for free by seeing red.
One minute, once your form renders
Tab through the form without touching the mouse: every field, then the submit button. Press Enter while focused in the name field. If it does not submit, your handler is on the button instead of the form.
Walkthrough
Create src/components/AddEquipmentForm.tsx. The parent owns the list, so the form takes a
callback prop and calls it — data down, events up.
import { useState } from "react";
import type { FormEvent } from "react";
import type { Equipment } from "../types";
export function AddEquipmentForm({ onAdd }: { onAdd: (item: Equipment) => void }) {
const [values, setValues] = useState<FormValues>(EMPTY);
const [errors, setErrors] = useState<Errors>({});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const found = validate(values);
setErrors(found);
if (Object.keys(found).length > 0) return;
onAdd({ id: crypto.randomUUID(), ...values });
setValues(EMPTY);
setErrors({});
}
return (
<form onSubmit={handleSubmit} noValidate>
<h2>Add equipment</h2>
{/* fields go here */}
<button type="submit">Add equipment</button>
</form>
);
}
Trace the order: prevent the reload, compute errors, store them so they render, and return early
if there are any. Only a clean run reaches onAdd, and only then do the two resets fire.
crypto.randomUUID() is a browser built-in that returns a fresh unique id string; it is available
on localhost, so it works in your Vite dev server.
In the parent, accept the item:
function handleAdd(item: Equipment) {
setItems([...items, item]);
}
New array, not push. The Day 59 rule has not moved.
Checkpoint
Submit the empty form. You should see two field errors, nothing added to the list, and no page reload. Fill both fields and submit: one new row, and both inputs go blank.
Your turn
Deliverable: a keyboard-usable form with clear errors and reset behavior.
- Add
FormValues,EMPTY,Errors, andvalidatetoAddEquipmentForm.tsxas above. - Build three controlled fields:
nameandlocationas text inputs,statusas a<select>with the three options. Every one needs a<label htmlFor>matching itsid. - Wire
aria-invalidandaria-describedbyon the two required fields, and render each message in an element whoseidmatches. - Add a form-level error region with
role="alert"that appears when `Object.keys(errors).length0`, saying how many fields need attention.
- Add a "Clear" button with
type="button"that callssetValues(EMPTY)andsetErrors({}). Confirm it does not submit the form. - Lift the list into state in the parent with
useState(equipment)and passhandleAdddown. Confirm a submitted item appears in the list immediately. - Keyboard-only pass: unplug the mouse. Tab to every control, submit with Enter, trigger the errors, and confirm the focus outline is visible at every stop.
You are done when
Submitting empty shows both messages and adds nothing; a valid submit adds a row and empties the fields; and the whole form is operable without a mouse.
Reviewer mode — after the form works
Today's mode is reviewer. Bring the finished component and ask for specific, actionable findings backed by evidence from your code — not general praise, and not a complete rewrite.
"Review the form for inaccessible errors, duplicated state, and validation that exists only in the UI."
That last phrase matters. Week 6 taught that the server validates every request, because the browser is under the user's control. Client-side validation is a courtesy that makes the form pleasant; it is never the check that protects your data.
Common pitfalls
- Forgetting
preventDefault. The page reloads, state resets, and it looks like your handler never ran. It ran — the browser navigated afterwards. - A secondary button without
type="button". "Clear" silently submits the form, becausesubmitis the default type inside a form. - Error text with no programmatic link. Red text near a field is invisible to a screen reader
user.
aria-describedbyis what attaches it. - Mutating the values object.
values.name = "x"changes nothing on screen: same reference, no re-render. AlwayssetValues({ ...values, name: "x" }).
Verify it yourself
Open today's reference, React's Learn section, and find the pages on reacting to input with state
and on <input> in the API reference.
- The
<input>reference explains the difference betweenvalueanddefaultValue. Find it, and write one sentence on when an uncontrolled field is the better choice. - React documents what happens if a controlled input's value starts as
undefinedand later becomes a string. Find the warning it produces, then reproduce it deliberately in your own form and read the console.
Record both in notes/day-60.md. Causing a warning on purpose, once, is how you recognise it
instantly the day it appears by accident.
The hour
- 0–5 min Recall
- 5–20 min Learn
Read only the listed concept notes and official reference sections needed today.
- 20–48 min Build
Create an Add Equipment form and validate required fields before submission.
- 48–55 min Explain and verify
Run the result, inspect evidence, and explain the data/control flow in your own words.
- 55–60 min Quiz and commit
Complete the quiz, record one lesson, and commit the verified change when applicable.
What to hand in
A keyboard-usable form with clear errors and reset behavior.
Working with AI today
Provide existing work and ask for concrete defects, risks, missing tests, and unsupported assumptions—not praise.
Review the form for inaccessible errors, duplicated state, and validation that exists only in the UI.
References
End-of-day quiz
Explain-back gate
Pass the quiz above to unlock completion.
Quiz + explain-back checks required.