React Forms: Controlled and Uncontrolled Inputs

React & Next.js: The Complete Developer HandbookLesson 8 of 54

Part 1: React Foundations · 8 of 16

Goal: build forms in React with controlled and uncontrolled inputs, validate them against real business rules, and show errors that every user, including screen reader users, can find and understand.

So far members can browse the catalogue and fill a basket. To actually borrow a tool they need to tell the library who they are and when they want it. That is a form, and forms are where a lot of real React code lives: typing, dates that depend on each other, rules that must be checked, and messages that must reach the right person. In this lesson you build the tool library’s ReservationForm and learn the two ways React can work with inputs.

Controlled inputs: React holds the value

An HTML input remembers what you type by itself: the text lives in the DOM element, and your code only knows it when it reads it. In a React app that splits the data in two places: the DOM knows what is typed, your state does not.

A controlled input removes that split. You give the input its value from state, and you update the state on every change. React state becomes the single source of truth, and the input simply displays it:

import { useState } from 'react';

function MemberName() {
  const [name, setName] = useState('Amina Khan');

  return (
    <div>
      <label htmlFor="member-name">Your name</label>{' '}
      <input
        id="member-name"
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
      <p>Reservation for: {name.trim() || '(no name yet)'}</p>
      <button onClick={() => setName('')}>Clear</button>
    </div>
  );
}

Type in the box and the line below follows every keystroke. Press Clear and the input empties, because its value comes from state and the state is now an empty string. Each keystroke goes round the loop from Lesson 1: onChange calls setName (trigger), React calls MemberName again (render) and puts the new value into the input (commit). React’s onChange fires on every keystroke, like the DOM’s input event, not only when the field loses focus.

The value and the onChange belong together. If you set value and forget the handler, React has no way to accept the user’s typing, so the field is stuck. Run this and try to type an email address:

import { useState } from 'react';

function MemberEmail() {
  const [email, setEmail] = useState('');

  return (
    <div>
      <label htmlFor="member-email">Email</label>{' '}
      <input id="member-email" type="email" value={email} />
      <p>Typed so far: {email}</p>
    </div>
  );
}

Nothing appears in the box, and React prints a warning under the result: you provided a value prop without an onChange handler. Add onChange={(e) => setEmail(e.target.value)} and it works. If a field really should be read-only, say so with the readOnly attribute and the warning goes away.

Other form elements follow the same pattern with small differences. A <textarea> and a <select> both take value and onChange, so you never need selected on an <option>. Checkboxes and radio buttons are controlled with checked, and you read e.target.checked (a boolean) instead of e.target.value.

Many fields, one state object

A reservation needs a tool, a start date and an end date. You could give each field its own useState, and for two or three fields that is perfectly fine. When the fields are submitted together, it is often tidier to keep them in one object and write a single change handler that uses each input’s name to know which key to update:

import { useState } from 'react';

const TOOLS = [
  { id: 't1', slug: 'cordless-drill', name: 'Cordless drill', category: 'Power tools', status: 'available', maxLoanDays: 7 },
  { id: 't8', slug: 'lawn-aerator', name: 'Lawn aerator', category: 'Garden', status: 'available', maxLoanDays: 5 },
];

function loanDays(start, end) {
  return (Date.parse(end) - Date.parse(start)) / 86400000 + 1;
}

function LoanDetails() {
  const [form, setForm] = useState({ toolId: 't1', startDate: '2026-12-01', endDate: '2026-12-03' });

  function handleChange(e) {
    const { name, value } = e.target;
    setForm((prev) => ({ ...prev, [name]: value }));
  }

  const tool = TOOLS.find((t) => t.id === form.toolId);
  const days = form.startDate && form.endDate ? loanDays(form.startDate, form.endDate) : null;

  return (
    <form>
      <label htmlFor="tool">Tool</label>{' '}
      <select id="tool" name="toolId" value={form.toolId} onChange={handleChange}>
        {TOOLS.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
      </select>
      <p>
        <label htmlFor="start">Start</label>{' '}
        <input id="start" type="date" name="startDate" value={form.startDate} onChange={handleChange} />{' '}
        <label htmlFor="end">End</label>{' '}
        <input id="end" type="date" name="endDate" value={form.endDate} onChange={handleChange} />
      </p>
      <p>{days === null ? 'Choose both dates.' : `${days} day(s). Limit for the ${tool.name.toLowerCase()}: ${tool.maxLoanDays} days.`}</p>
    </form>
  );
}

It starts with “3 day(s). Limit for the cordless drill: 7 days.” Change the tool or a date and the sentence updates. Three things in this example are worth copying:

  • The update is immutable. { ...prev, [name]: value } copies the object and replaces one key, as in Lesson 5. The square brackets are a computed property name: the key is whatever string name holds.
  • The loan length is derived, not stored. It is calculated from the two dates during render, so it can never disagree with them. There is no days state to forget to update.
  • Date inputs give you strings. A date input’s value is always "YYYY-MM-DD", or an empty string when nothing is chosen. Strings in that format sort in date order, so end < start compares dates correctly. Date.parse reads that format as midnight UTC, so subtracting two of them gives a whole number of days with no time zone surprises. The loan counts both the first and the last day, hence the + 1.

Validating against the library’s rules

The tool library has four rules for a loan: it lasts from 1 day up to the tool’s maxLoanDays, the start date cannot be in the past, the end date is on or after the start date, and a member holds at most three tools at once. The first three belong to this form; the basket already enforces the last one (Lesson 5).

Put the rules in a plain function that takes the values and returns an object of error messages, keyed by field name. It knows nothing about React, which makes it easy to read, reuse and test. You can run it without any component at all:

function loanDays(start, end) {
  return (Date.parse(end) - Date.parse(start)) / 86400000 + 1;
}

function validateDates({ startDate, endDate }, tool, today) {
  const errors = {};
  if (!startDate) {
    errors.startDate = 'Choose a start date.';
  } else if (startDate < today) {
    errors.startDate = 'The start date cannot be in the past.';
  }
  if (!endDate) {
    errors.endDate = 'Choose an end date.';
  } else if (startDate && endDate < startDate) {
    errors.endDate = 'The end date must be on or after the start date.';
  } else if (startDate && loanDays(startDate, endDate) > tool.maxLoanDays) {
    errors.endDate = `This tool can be borrowed for at most ${tool.maxLoanDays} days.`;
  }
  return errors;
}

const drill = { id: 't1', name: 'Cordless drill', maxLoanDays: 7 };
const today = '2026-11-05';

console.log(validateDates({ startDate: '2026-11-10', endDate: '2026-11-12' }, drill, today));
console.log(validateDates({ startDate: '2026-11-01', endDate: '' }, drill, today));
console.log(validateDates({ startDate: '2026-11-10', endDate: '2026-11-08' }, drill, today));
console.log(validateDates({ startDate: '2026-11-10', endDate: '2026-11-20' }, drill, today));

The console shows an empty object for the valid loan, then an error for each broken rule: a start date in the past plus a missing end date, an end date before the start, and an 11-day loan for a tool with a 7-day limit. Passing today in, instead of reading the clock inside, keeps the function pure: the same inputs always give the same answer, and a test can pick any date it likes.

Now, when should the form show those errors? Shouting “Choose a start date” at someone who has not touched the form yet is unfriendly. A simple, solid approach: calculate the errors on every render (they are derived from the values, like the loan length), and only display them once the member has tried to submit. That takes one extra boolean in state, submitted, instead of a separate copy of the errors that could go stale. The next example puts this together.

One rule for the rest of your career: client-side validation is a convenience, not security. Anyone can switch JavaScript off or send a request without your form. The server must check the same rules again before it saves a reservation. Lesson 37 does that with a schema in Next.js.

Labels and accessible error messages

A red sentence next to a field is clear if you can see it. A screen reader user moving from field to field needs the same information announced, and that takes a few attributes:

  • A real label. <label htmlFor="start-date"> tied to id="start-date" gives the field its name, and clicking the label focuses the field. JSX uses htmlFor because for is a reserved word in JavaScript. A placeholder is not a label: it disappears as soon as you type.
  • aria-invalid on the input when its value is wrong, so assistive technology reports the field as invalid.
  • aria-describedby pointing at the id of the error message, so the message is read out together with the label when the field gets focus.

Here is the date part of the reservation form with all of that in place. Press Reserve without choosing dates:

import { useState } from 'react';

function todayISO() {
  const now = new Date();
  return new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 10);
}
function loanDays(start, end) {
  return (Date.parse(end) - Date.parse(start)) / 86400000 + 1;
}
function validateDates({ startDate, endDate }, maxDays, today) {
  const errors = {};
  if (!startDate) errors.startDate = 'Choose a start date.';
  else if (startDate < today) errors.startDate = 'The start date cannot be in the past.';
  if (!endDate) errors.endDate = 'Choose an end date.';
  else if (startDate && endDate < startDate) errors.endDate = 'The end date must be on or after the start date.';
  else if (startDate && loanDays(startDate, endDate) > maxDays) errors.endDate = `At most ${maxDays} days for this tool.`;
  return errors;
}

function DateField({ id, name, label, value, min, error, onChange }) {
  return (
    <p>
      <label htmlFor={id}>{label}</label>{' '}
      <input id={id} name={name} type="date" value={value} min={min} onChange={onChange}
        aria-invalid={error ? true : undefined} aria-describedby={error ? `${id}-error` : undefined} />
      {error && <span id={`${id}-error`} style={{ display: 'block', color: '#b00020' }}>{error}</span>}
    </p>
  );
}

function ReserveDrill() {
  const [dates, setDates] = useState({ startDate: '', endDate: '' });
  const [submitted, setSubmitted] = useState(false);
  const [message, setMessage] = useState('');
  const today = todayISO();
  const errors = validateDates(dates, 7, today);
  const shown = submitted ? errors : {};

  function handleChange(e) {
    setDates({ ...dates, [e.target.name]: e.target.value });
    setMessage('');
  }
  function handleSubmit(e) {
    e.preventDefault();
    setSubmitted(true);
    const ok = Object.keys(errors).length === 0;
    setMessage(ok ? `Cordless drill reserved for ${loanDays(dates.startDate, dates.endDate)} day(s).` : '');
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <DateField id="start-date" name="startDate" label="Start date" value={dates.startDate} min={today} error={shown.startDate} onChange={handleChange} />
      <DateField id="end-date" name="endDate" label="End date" value={dates.endDate} min={dates.startDate || today} error={shown.endDate} onChange={handleChange} />
      <button type="submit">Reserve</button>
      <p>{message}</p>
    </form>
  );
}

Both messages appear under their fields: “Choose a start date.” and “Choose an end date.” Pick dates and the messages update as you type, because they are recalculated on every render. Choose a start date of today and an end date ten days later, and the end date error changes to “At most 7 days for this tool.” Valid dates show the confirmation instead.

A few choices in that code are deliberate. The min attribute helps the browser’s date picker grey out impossible days, but people can still type a past date, so the rule is checked in code as well. noValidate on the form switches off the browser’s built-in error bubbles, so the member sees one consistent set of messages; the type="date" and, later, type="email" still bring the right picker and the right mobile keyboard. todayISO builds the date in the member’s own time zone; new Date().toISOString() alone would give the UTC date, which is a day off for part of each day in many places. And e.preventDefault() stops the page reloading, as in Lesson 6.

For a long form, two more habits help: show a short summary of the errors at the top, and move keyboard focus to the first invalid field after a failed submit, so its label and message are announced straight away. Moving focus needs a ref, which Lesson 9 introduces.

Uncontrolled inputs and FormData

Controlled inputs are not the only option. An uncontrolled input is one where you let the browser keep the value, just as in plain HTML. You can give it a starting value with defaultValue (or defaultChecked for checkboxes), and you read the values when you need them, usually on submit. The browser’s FormData object collects every field that has a name in one go:

import { useState } from 'react';

function QuickRequest() {
  const [sent, setSent] = useState(null);

  function handleSubmit(e) {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    setSent(Object.fromEntries(formData));
  }

  return (
    <form onSubmit={handleSubmit}>
      <p>
        <label htmlFor="q-name">Name</label>{' '}
        <input id="q-name" name="memberName" defaultValue="Amina Khan" />
      </p>
      <p>
        <label htmlFor="q-tool">Tool</label>{' '}
        <select id="q-tool" name="toolId" defaultValue="t6">
          <option value="t1">Cordless drill</option>
          <option value="t6">Tile cutter</option>
        </select>
      </p>
      <p>
        <label htmlFor="q-note">Note for the volunteer</label>{' '}
        <input id="q-note" defaultValue="I can collect on Saturday" />
      </p>
      <button type="submit">Send request</button>
      {sent && <pre>{JSON.stringify(sent, null, 2)}</pre>}
    </form>
  );
}

Press Send request and the form’s data appears as JSON: memberName and toolId, but no note. The note input has no name attribute, so FormData skips it. That is the most common surprise with this approach. Add name="note" and send again. Also note that typing in these fields causes no re-render at all; React is not involved until the submit.

e.currentTarget is always the element whose handler is running, here the form. Every value in FormData is a string (or a File for file inputs), so convert numbers yourself. The same validateDates function works on the object you get from FormData: the rules don’t change with the approach.

React 19 also lets you pass a function to a form’s action prop. React then calls it with the FormData for you and resets uncontrolled fields after it succeeds. That pairs with useActionState and useFormStatus, and gets its own section in Lesson 15. The onSubmit pattern here is still common and worth knowing well.

Which one should you use?

Neither is “the React way”; they suit different jobs. Reach for controlled inputs when the UI must react to the value while the user types:

  • live feedback, such as the loan length or an error that clears as soon as the date is fixed;
  • fields that depend on each other, such as the end date’s min following the start date;
  • changing the value from code, such as a Clear button or filling in a signed-in member’s name;
  • filtering as you type, like the catalogue search in Lesson 7.

Reach for uncontrolled inputs with FormData when you only need the values at the end: a simple contact or feedback form, a form with many fields and no live behaviour, or a form handled by a form action. They are less code and cause no re-renders while typing. Mixing is fine too: control the two date fields that need live checks and leave a free-text note uncontrolled. What you must not do is switch a single input between the two modes, which the Common mistakes section shows.

Common mistakes

  • value without onChange. The field cannot be edited and React warns in the console. In review, every value= on an editable input should have an onChange next to it, or a readOnly.
  • Switching from uncontrolled to controlled. Starting a value as undefined (for example useState(), or a field missing from an object loaded later) makes the input uncontrolled, then it becomes controlled when a string arrives, and React warns that “a component is changing an uncontrolled input to be controlled”. Always start controlled text inputs with ''.
  • Storing errors or derived values in their own state. A separate errors state set in a change handler easily goes stale when another field changes. Calculate errors from the values during render, and keep only “has the user tried to submit?” in state.
  • Missing name or label. An uncontrolled field without name silently disappears from FormData; a field with only a placeholder has no accessible name. Check that every input has a <label htmlFor> matching its id.
  • Trusting the browser. Forgetting preventDefault() reloads the page and loses the state; treating client checks as final lets bad data through. Validate again on the server.

Build it: the reservation form

Finish the tool library’s ReservationForm for the lawn aerator (5-day limit). The starter has controlled fields for the member’s name, email and both dates. Add:

  • a validate function with the date rules from this lesson, plus “Enter your name.” for an empty (or spaces-only) name and “Enter an email address like name@example.com.” when the email does not look like one;
  • errors shown only after the first submit, each linked to its input with aria-describedby and flagged with aria-invalid;
  • on a valid submit, the text “Thanks, Amina. Lawn aerator reserved from 2026-12-01 to 2026-12-03 (3 days).” with the real values.
import { useState } from 'react';

const tool = { id: 't8', slug: 'lawn-aerator', name: 'Lawn aerator', category: 'Garden', status: 'available', maxLoanDays: 5 };

function ReservationForm() {
  const [values, setValues] = useState({ memberName: '', email: '', startDate: '', endDate: '' });

  function handleChange(e) {
    setValues({ ...values, [e.target.name]: e.target.value });
  }

  return (
    <form noValidate onSubmit={(e) => e.preventDefault()}>
      <h3>Reserve the {tool.name.toLowerCase()}</h3>
      <p><label htmlFor="r-name">Name</label>{' '}
        <input id="r-name" name="memberName" value={values.memberName} onChange={handleChange} /></p>
      <p><label htmlFor="r-email">Email</label>{' '}
        <input id="r-email" name="email" type="email" value={values.email} onChange={handleChange} /></p>
      <p><label htmlFor="r-start">Start date</label>{' '}
        <input id="r-start" name="startDate" type="date" value={values.startDate} onChange={handleChange} /></p>
      <p><label htmlFor="r-end">End date</label>{' '}
        <input id="r-end" name="endDate" type="date" value={values.endDate} onChange={handleChange} /></p>
      <button type="submit">Reserve</button>
    </form>
  );
}

Hint: a small Field component that takes id, label, error and the rest of the input’s props ({ id, label, error, ...inputProps }) saves you writing the label, the ARIA attributes and the message four times. For the email, a deliberately loose check such as /^\S+@\S+\.\S+$/ is enough on the client; the only real proof that an address works is a confirmation email.

Show a sample solution
import { useState } from 'react';

const tool = { id: 't8', slug: 'lawn-aerator', name: 'Lawn aerator', category: 'Garden', status: 'available', maxLoanDays: 5 };

function todayISO() {
  const now = new Date();
  return new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 10);
}
const loanDays = (start, end) => (Date.parse(end) - Date.parse(start)) / 86400000 + 1;

function validate(v, today) {
  const errors = {};
  if (!v.memberName.trim()) errors.memberName = 'Enter your name.';
  if (!/^\S+@\S+\.\S+$/.test(v.email)) errors.email = 'Enter an email address like name@example.com.';
  if (!v.startDate) errors.startDate = 'Choose a start date.';
  else if (v.startDate < today) errors.startDate = 'The start date cannot be in the past.';
  if (!v.endDate) errors.endDate = 'Choose an end date.';
  else if (v.startDate && v.endDate < v.startDate) errors.endDate = 'The end date must be on or after the start date.';
  else if (v.startDate && loanDays(v.startDate, v.endDate) > tool.maxLoanDays) errors.endDate = `At most ${tool.maxLoanDays} days for this tool.`;
  return errors;
}

function Field({ id, label, error, ...inputProps }) {
  return (
    <p>
      <label htmlFor={id}>{label}</label>{' '}
      <input id={id} {...inputProps} aria-invalid={error ? true : undefined} aria-describedby={error ? `${id}-error` : undefined} />
      {error && <span id={`${id}-error`} style={{ display: 'block', color: '#b00020' }}>{error}</span>}
    </p>
  );
}

function ReservationForm() {
  const [values, setValues] = useState({ memberName: '', email: '', startDate: '', endDate: '' });
  const [submitted, setSubmitted] = useState(false);
  const [confirmation, setConfirmation] = useState('');
  const today = todayISO();
  const errors = validate(values, today);
  const shown = submitted ? errors : {};

  function handleChange(e) {
    setValues({ ...values, [e.target.name]: e.target.value });
    setConfirmation('');
  }
  function handleSubmit(e) {
    e.preventDefault();
    setSubmitted(true);
    if (Object.keys(errors).length > 0) return;
    const { memberName, startDate, endDate } = values;
    setConfirmation(`Thanks, ${memberName.trim()}. ${tool.name} reserved from ${startDate} to ${endDate} (${loanDays(startDate, endDate)} days).`);
  }
  const field = (name) => ({ name, value: values[name], error: shown[name], onChange: handleChange });

  return (
    <form noValidate onSubmit={handleSubmit}>
      <h3>Reserve the {tool.name.toLowerCase()}</h3>
      <Field id="res-name" label="Name" autoComplete="name" {...field('memberName')} />
      <Field id="res-email" label="Email" type="email" autoComplete="email" {...field('email')} />
      <Field id="res-start" label="Start date" type="date" min={today} {...field('startDate')} />
      <Field id="res-end" label="End date" type="date" min={values.startDate || today} {...field('endDate')} />
      <button type="submit">Reserve</button>
      <p>{confirmation}</p>
    </form>
  );
}

Press Reserve on the empty form and four messages appear, one under each field. The values are the only form state; the errors are derived from them on every render, and submitted only decides whether to show them. Field spreads the remaining props onto the input, so type, min and autoComplete pass straight through, and the ARIA wiring is written once. The autoComplete values let browsers fill in the member’s saved name and email. A one-day loan shows “(1 days)”; handling singular and plural properly is a nice extra step.

Check your understanding

Answer each question in a sentence or two before opening the answer.

1. The loan-length text and the end-date error both depend on the two dates. Why is neither of them kept in state?

Both can be calculated from the dates during render. Storing them would mean a second copy of the same information that has to be updated in every handler, and the first time one handler forgets, the screen shows a loan length or an error that no longer matches the dates. Deriving them keeps one source of truth.

2. A teammate’s tool-search input ignores typing and the console says a value was provided without an onChange handler. What is wrong, and what are the two possible fixes?

The input is controlled (it has a value from state) but nothing updates that state, so React keeps putting the old value back. Either add onChange that calls the setter, making it a proper controlled input, or remove value and use defaultValue, making it uncontrolled and reading it on submit.

3. How would you check, without a screen reader, that the end-date error is announced with its field?

Submit the form with a bad end date, then inspect the input in the browser’s developer tools. It should have aria-invalid="true" and an aria-describedby value that matches the id of the message element. The Accessibility panel in Chrome or Firefox DevTools shows the field’s computed name (from the label) and description (the error text). If the description is empty, the ids don’t match. An automated checker such as axe catches missing labels too.

Key takeaways

  • A controlled input takes value from state and updates it with onChange; React state is the single source of truth.
  • An uncontrolled input keeps its value in the DOM; set a starting value with defaultValue and read every named field with new FormData(form).
  • Keep validation rules in a pure function and derive errors during render; store only the values and whether the user has tried to submit.
  • Every field needs a label; errors need aria-invalid and aria-describedby. Client checks help the user, and the server checks again.

Priority: should know. You should be able to build a validated, accessible form either way and explain why you chose controlled or uncontrolled inputs for it. For the exact props, see the <input> reference and the <form> reference in the React documentation.

Code examples in this course are free to use under the MIT licence. Lesson text © 2026 Farooq Bin Munir.