React Lists and Forms: Controlled Inputs, Validation and Accessible Labels

MERN Stack Full Course: MongoDB, Express, React and Node.js · React Fundamentals · 6 min read · Updated

A controlled input

  1. In a controlled input, React state is the single source of truth for what is in the field.
  2. Each key press fires onChange, which updates the state.
  3. The input shows the state through its value prop, so they always agree.
  4. On submit we validate the state first and show clear messages next to the fields.
  5. Every input has a label tied to it, which helps screen readers and makes the click area larger. Lists use a stable key, the id.

Forms are where most web apps spend most of their time, and where most bugs and accessibility problems live. In React the standard approach is the controlled input: the input's value always comes from state, and every keystroke goes back into state. React is then the single source of truth, so you can validate, reset or change what is shown at any moment.

The form component

import { useState } from 'react';
import { STATUSES } from './JobCard.jsx';

const EMPTY = { company: '', role: '', location: '', status: 'saved' };

// A controlled form: every input's value comes from state, and every change goes back into state.
export default function JobForm({ onSubmit, initial = EMPTY, submitLabel = 'Add job' }) {
  const [values, setValues] = useState(initial);
  const [errors, setErrors] = useState({});

  const change = (e) => setValues((v) => ({ ...v, [e.target.name]: e.target.value }));

  function submit(e) {
    e.preventDefault(); // stop the browser from reloading the page
    const found = {};
    if (!values.company.trim()) found.company = 'Company is required';
    if (!values.role.trim()) found.role = 'Role is required';
    setErrors(found);
    if (Object.keys(found).length) return;
    onSubmit(values);
    setValues(EMPTY);
  }

  return (
    <form className="card form" onSubmit={submit} noValidate>
      <div className="field">
        <label htmlFor="company">Company</label>
        <input id="company" name="company" value={values.company} onChange={change} aria-invalid={Boolean(errors.company)} />
        {errors.company && <p className="error">{errors.company}</p>}
      </div>
      <div className="field">
        <label htmlFor="role">Role</label>
        <input id="role" name="role" value={values.role} onChange={change} aria-invalid={Boolean(errors.role)} />
        {errors.role && <p className="error">{errors.role}</p>}
      </div>
      <div className="field">
        <label htmlFor="location">Location</label>
        <input id="location" name="location" value={values.location} onChange={change} />
      </div>
      <div className="field">
        <label htmlFor="status">Status</label>
        <select id="status" name="status" value={values.status} onChange={change}>
          {STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
        </select>
      </div>
      <button className="btn" type="submit">{submitLabel}</button>
    </form>
  );
}
  • `values` holds what is in the fields. `errors` holds a message per field. Both are state.
  • One `change` function for all fields. Each input has a name that matches a key in values, so e.target.name says which field changed, and [e.target.name]: e.target.value is a *computed key* that updates it. That avoids one handler per field.
  • `e.preventDefault()` in submit stops the browser's default of sending the form and reloading the page.
  • `noValidate` on the form turns off the browser's pop-up bubbles so that our own messages, which can match the site's style and language, are used. The server validates too (lesson 12), so nothing depends on this.

Accessibility is not optional

Several small details here make the form usable by everybody, and they cost almost nothing.

  • Every input has a `<label htmlFor>` that matches the input's id. Clicking the label focuses the field, and screen readers announce it. Placeholder text is not a label: it disappears when you type.
  • `aria-invalid` marks a field with a problem, and the red border uses the same attribute in CSS, so the visual and the semantic state cannot drift apart.
  • Error text sits right under its field. For messages that appear after an action, the next lessons add role="alert" so screen readers announce them.
  • Use a real <button type="submit"> inside a real <form>: Enter then submits the form for free.

Using the form and drawing the list

import { useState } from 'react';
import Header from './components/Header.jsx';
import JobCard from './components/JobCard.jsx';
import JobForm from './components/JobForm.jsx';

const SAMPLE = [
  { id: 1, company: 'Northwind Bank', role: 'Teller', location: 'Leeds', status: 'applied' },
  { id: 2, company: 'Acme Logistics', role: 'Dispatcher', location: 'Pune', status: 'interview' },
];

export default function App() {
  const [jobs, setJobs] = useState(SAMPLE);

  const changeStatus = (id, status) => setJobs((cur) => cur.map((j) => (j.id === id ? { ...j, status } : j)));
  // The form hands us the values; we add a job with a made-up id. Lesson 31 saves it on the server instead.
  const addJob = (values) => setJobs((cur) => [{ ...values, id: Date.now() }, ...cur]);

  return (
    <>
      <Header />
      <main className="container">
        <h1>My jobs ({jobs.length})</h1>
        <JobForm onSubmit={addJob} />
        {jobs.length === 0 && <p className="muted">No jobs yet. Add your first one above.</p>}
        {/* A list needs a stable key so React can tell the items apart when the list changes. */}
        {jobs.map((job) => <JobCard key={job.id} job={job} onStatusChange={changeStatus} />)}
      </main>
    </>
  );
}

App gives the form an onSubmit function and adds the new job to the list with a spread: [{ ...values, id: Date.now() }, ...cur]. The list is drawn with .map() and a key. Notice the three states of the list: items exist; nothing exists yet (we show a friendly message); and, in lesson 28, loading. Always design all three, because an empty screen with no explanation looks like a bug.

The job is still only added to the screen. Reload the page and it is gone. Saving it on the server is lesson 31. Doing it in steps keeps each idea clear.

Where should state live?

A common beginner question is where to put state. A good rule is: put it in the closest component that needs it, and lift it up only when two siblings need to share it. The form's values are only used by the form, so they live there. The list of jobs is used by the list and by the form's submit handler, so it lives in App, their common parent.

Your task

  1. Run the app. Submit the empty form and read the errors. Fix one field and submit again.
  2. Use the keyboard only: tab through the fields, choose a status with the arrow keys, and submit with Enter.
  3. Add a Pay field (salaryNote) and a Notes textarea to the form.
  4. Add a check that blocks company names longer than 120 characters, with a message.

How to check it worked

  • Errors appear under the fields and clear when the form is valid.
  • A new job appears at the top and the form resets.
  • You can complete the whole flow without a mouse.

Summary

  • A controlled input reads its value from state and writes every change back.
  • One onChange with computed keys can serve all fields.
  • Labels, aria-invalid and real buttons make a form usable for everyone.

Download the solution (mern-lesson-27.zip)

Frequently asked questions

What is a controlled input?

An input whose value is set by React state and updated on every change event. React is the single source of truth, which makes validation, resetting and conditional logic simple.

Why not use the browser's built-in validation?

You can, and it is a good fallback. Custom validation lets you control the wording, placement and styling, and matches the server's messages. In either case the server must validate again.

Is a placeholder enough as a label?

No. A placeholder disappears when you type and is not reliably announced by screen readers. Use a visible label connected with htmlFor and id.