Skip to content
elephantoo

Forms & controlled inputs

Lesson 9 of 30 18 min read

Text, checkbox, select and radio inputs, controlled vs uncontrolled, validation and submission.


Forms are how users give your app information: sign-ups, searches, settings, checkouts, comments. React offers two approaches: controlled inputs, where React state drives every field, and uncontrolled inputs, where the DOM holds the values and you read them on submit. This lesson covers both, every common input type, validation and submission.

Controlled inputs#

A controlled input gets its value from state and reports every change through onChange:

JSX
import { useState } from "react";

export default function NameForm() {
  const [name, setName] = useState("");

  return (
    <form onSubmit={(e) => e.preventDefault()}>
      <label htmlFor="name">Your name</label>
      <input id="name" value={name} onChange={(e) => setName(e.target.value)} />
      <p>Hello, {name.trim() || "stranger"}!</p>
    </form>
  );
}

The cycle on every keystroke: the user types → onChange fires → setName updates state → React re-renders → the input shows the new value. React state is the single source of truth.

That gives you full control:

JSX
function Controlled() {
  const [code, setCode] = useState("");
  const [username, setUsername] = useState("");

  return (
    <>
      {/* Force uppercase and a max length while typing */}
      <input value={code} onChange={(e) => setCode(e.target.value.toUpperCase().slice(0, 6))} />

      {/* Live validation feedback */}
      <input value={username} onChange={(e) => setUsername(e.target.value)} />
      {username.length > 0 && username.length < 3 && <p>Too short</p>}
    </>
  );
}

If you set value without onChange, the input is read-only and React warns you. Use readOnly if that's what you want, or defaultValue for an uncontrolled input.

Every input type#

JSX
import { useState } from "react";

export default function SettingsForm() {
  const [form, setForm] = useState({
    email: "",
    bio: "",
    plan: "free",
    newsletter: true,
    theme: "system",
    age: "",
  });

  function update(e) {
    const { name, type, value, checked } = e.target;
    setForm((f) => ({ ...f, [name]: type === "checkbox" ? checked : value }));
  }

  return (
    <form onSubmit={(e) => e.preventDefault()}>
      <label>
        Email
        <input name="email" type="email" value={form.email} onChange={update} />
      </label>

      <label>
        Bio
        <textarea name="bio" rows={3} value={form.bio} onChange={update} />
      </label>

      <label>
        Plan
        <select name="plan" value={form.plan} onChange={update}>
          <option value="free">Free</option>
          <option value="pro">Pro</option>
        </select>
      </label>

      <label>
        <input name="newsletter" type="checkbox" checked={form.newsletter} onChange={update} />
        Send me the newsletter
      </label>

      <fieldset>
        <legend>Theme</legend>
        {["light", "dark", "system"].map((t) => (
          <label key={t}>
            <input type="radio" name="theme" value={t} checked={form.theme === t} onChange={update} />
            {t}
          </label>
        ))}
      </fieldset>

      <label>
        Age
        <input name="age" type="number" min={13} value={form.age} onChange={update} />
      </label>

      <pre>{JSON.stringify(form, null, 2)}</pre>
    </form>
  );
}

Things to notice:

  • One handler for many fields: each input has a name matching a key in state, and the computed property [name] updates the right one.
  • <textarea> and <select> use value in React (not inner text or selected attributes).
  • Checkboxes use checked and e.target.checked.
  • Radio buttons share a name; each is checked when its value matches state.
  • Number inputs still give you strings — convert with Number(form.age) when you use them, and keep "" for "empty".
  • A <label> wrapping the input (or htmlFor + id) makes the label clickable and accessible.

Submitting a form#

JSX
import { useState } from "react";

export default function ContactForm() {
  const [values, setValues] = useState({ name: "", email: "", message: "" });
  const [status, setStatus] = useState("idle"); // idle | sending | sent | error

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

  async function handleSubmit(e) {
    e.preventDefault();
    setStatus("sending");
    try {
      const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(values),
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      setStatus("sent");
      setValues({ name: "", email: "", message: "" });
    } catch {
      setStatus("error");
    }
  }

  if (status === "sent") return <p>Thanks! We'll be in touch.</p>;

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" value={values.name} onChange={handleChange} placeholder="Name" required />
      <input name="email" type="email" value={values.email} onChange={handleChange} placeholder="Email" required />
      <textarea name="message" value={values.message} onChange={handleChange} placeholder="Message" required />
      <button type="submit" disabled={status === "sending"}>
        {status === "sending" ? "Sending…" : "Send"}
      </button>
      {status === "error" && <p role="alert">Something went wrong. Please try again.</p>}
    </form>
  );
}

Disable the button while sending to prevent double submissions, and always show success and error states. Built-in HTML validation (required, type="email", min, pattern) still works and runs before onSubmit.

Validation#

For custom rules, write a pure function that returns errors, and show them after the user has interacted with a field:

JSX
import { useState } from "react";

function validate({ username, password, confirm }) {
  const errors = {};
  if (username.trim().length < 3) errors.username = "At least 3 characters.";
  if (password.length < 8) errors.password = "At least 8 characters.";
  else if (!/\d/.test(password)) errors.password = "Include a number.";
  if (confirm !== password) errors.confirm = "Passwords don't match.";
  return errors;
}

export default function SignupForm() {
  const [values, setValues] = useState({ username: "", password: "", confirm: "" });
  const [touched, setTouched] = useState({});
  const errors = validate(values);              // derived – no extra state
  const isValid = Object.keys(errors).length === 0;

  const change = (e) => setValues({ ...values, [e.target.name]: e.target.value });
  const blur = (e) => setTouched({ ...touched, [e.target.name]: true });

  function handleSubmit(e) {
    e.preventDefault();
    setTouched({ username: true, password: true, confirm: true });
    if (!isValid) return;
    console.log("Creating account for", values.username);
  }

  function field(name, label, type = "text") {
    const showError = touched[name] && errors[name];
    return (
      <div>
        <label htmlFor={name}>{label}</label>
        <input
          id={name}
          name={name}
          type={type}
          value={values[name]}
          onChange={change}
          onBlur={blur}
          aria-invalid={showError ? true : undefined}
          aria-describedby={showError ? `${name}-error` : undefined}
        />
        {showError && (
          <p id={`${name}-error`} className="error">
            {errors[name]}
          </p>
        )}
      </div>
    );
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      {field("username", "Username")}
      {field("password", "Password", "password")}
      {field("confirm", "Confirm password", "password")}
      <button type="submit">Create account</button>
    </form>
  );
}
  • Errors are derived from values on each render, so they're never stale.
  • touched avoids shouting at users before they've typed anything.
  • aria-invalid and aria-describedby make errors accessible to screen readers.

For large forms, libraries like React Hook Form (often with Zod for schemas) handle this boilerplate efficiently. And always validate again on the server.

Uncontrolled inputs#

Not every form needs state for every keystroke. With uncontrolled inputs the DOM keeps the values; you read them when the form is submitted, using FormData:

JSX
export default function QuickSearch({ onSearch }) {
  function handleSubmit(e) {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    onSearch?.({ q: data.get("q"), level: data.get("level") });
    e.currentTarget.reset();
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="q" defaultValue="" placeholder="Search…" />
      <select name="level" defaultValue="any">
        <option value="any">Any level</option>
        <option value="beginner">Beginner</option>
      </select>
      <button type="submit">Search</button>
    </form>
  );
}

Use defaultValue / defaultChecked to set initial values without controlling them.

ControlledUncontrolled
Source of truthReact stateThe DOM
Live validation, formatting, dependent fields✅ Easy❌ Awkward
Code neededMoreLess
Good forInteractive forms, search-as-you-typeSimple forms read on submit, file inputs

File inputs (<input type="file">) are always uncontrolled — their value can only be set by the user. Read e.target.files in onChange, or use FormData on submit.

React 19 makes the uncontrolled FormData approach even nicer with form actions (<form action={fn}>) and useActionState — you'll learn them in the React 19 lesson.

Common mistakes#

  • Initialising state as undefined or null and getting the controlled/uncontrolled warning — use "".
  • Setting value without onChange.
  • Forgetting e.preventDefault() in onSubmit, causing a page reload.
  • Treating number input values as numbers.
  • Mutating the form object: form.email = x; setForm(form).
  • Missing labels — placeholders aren't labels.

What's next#

You've finished the fundamentals! Next, go deeper into effects with useEffect and the component lifecycle.

Check your understanding

Quick quiz

0/3 answered
  1. 1.What makes an input *controlled*?

  2. 2.Which prop do you use to control a checkbox?

  3. 3.React warns: *A component is changing an uncontrolled input to be controlled*. What's a common cause?

Finished reading?

Mark this lesson complete to track your progress.