0 / 91
Week 9 · Day 60 of 91

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.

  1. Add FormValues, EMPTY, Errors, and validate to AddEquipmentForm.tsx as above.
  2. Build three controlled fields: name and location as text inputs, status as a <select> with the three options. Every one needs a <label htmlFor> matching its id.
  3. Wire aria-invalid and aria-describedby on the two required fields, and render each message in an element whose id matches.
  4. Add a form-level error region with role="alert" that appears when `Object.keys(errors).length

    0`, saying how many fields need attention.

  5. Add a "Clear" button with type="button" that calls setValues(EMPTY) and setErrors({}). Confirm it does not submit the form.
  6. Lift the list into state in the parent with useState(equipment) and pass handleAdd down. Confirm a submitted item appears in the list immediately.
  7. 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, because submit is 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-describedby is what attaches it.
  • Mutating the values object. values.name = "x" changes nothing on screen: same reference, no re-render. Always setValues({ ...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.

  1. The <input> reference explains the difference between value and defaultValue. Find it, and write one sentence on when an uncontrolled field is the better choice.
  2. React documents what happens if a controlled input's value starts as undefined and 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

  1. 0–5 min Recall

    Without notes, state yesterday’s main idea and one unresolved question.

  2. 5–20 min Learn

    Read only the listed concept notes and official reference sections needed today.

  3. 20–48 min Build

    Create an Add Equipment form and validate required fields before submission.

  4. 48–55 min Explain and verify

    Run the result, inspect evidence, and explain the data/control flow in your own words.

  5. 55–60 min Quiz and commit

    Complete the quiz, record one lesson, and commit the verified change when applicable.

What to hand in

Deliverable

A keyboard-usable form with clear errors and reset behavior.

Working with AI today

AI as skeptical reviewer

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

Q1 What makes an input controlled?
Q2 Which result best proves today’s work is complete?
Q3 What should an AI code review primarily produce?

Explain-back gate

Pass the quiz above to unlock completion.

Quiz + explain-back checks required.