Forms and Controlled Inputs

Controlled versus uncontrolled inputs, and validating what the user typed.

What you'll learn

  • What makes an input controlled, and why you would want that
  • Handling many fields without writing many handlers
  • Checkboxes, selects, and textareas
  • Validating, and submitting without reloading the page

Controlled inputs

An input is controlled when React supplies its value and an onChange handler puts every keystroke back into state. State is the single source of truth; the DOM only displays it.

That round trip is what lets you react to typing — transform it, validate it, or disable a button:

import { useState } from "react";

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

  return (
    <div style={{ fontFamily: "sans-serif", padding: 16, fontSize: 13 }}>
      <input
        value={name}
        onChange={(e) => setName(e.target.value.toUpperCase())}
        placeholder="Type your name"
      />

      <p>value in state: {name || "(empty)"}</p>
      <button disabled={name.length < 3}>Submit ({name.length} chars)</button>
    </div>
  );
}

Typing lowercase produces uppercase, because what you see is state, not what you typed. The button enables itself at three characters.

A value without an onChange makes the input read-only, which is a common first surprise. If you want a starting value the user can then edit freely, use defaultValue — that is an uncontrolled input, where the DOM keeps the value and you read it only when you need it.

Prefer controlled inputs. Reach for uncontrolled ones for simple fields where nothing needs to react to each keystroke, or for <input type="file" />, which is always uncontrolled.

Many fields, one handler

A handler per field does not scale. Keep the fields in one object and use the input's name to decide which key to update:

import { useState } from "react";

export default function App() {
  const [form, setForm] = useState({ name: "", email: "", role: "member" });

  // One handler for every field. [name] is a computed property key.
  function handleChange(event) {
    const { name, value } = event.target;
    setForm((current) => ({ ...current, [name]: value }));
  }

  return (
    <div style={{ fontFamily: "sans-serif", padding: 16, fontSize: 13 }}>
      <p><input name="name" value={form.name} onChange={handleChange} placeholder="Name" /></p>
      <p><input name="email" value={form.email} onChange={handleChange} placeholder="Email" /></p>
      <p>
        <select name="role" value={form.role} onChange={handleChange}>
          <option value="member">Member</option>
          <option value="admin">Admin</option>
        </select>
      </p>

      <pre style={{ background: "#f4f4f5", padding: 8 }}>{JSON.stringify(form, null, 2)}</pre>
    </div>
  );
}

Note the updater form, setForm((current) => ...), and the copy. Both are the rules from useState: never mutate the object, and derive the next value from the previous one.

The other input types

Three of them do not use value the way a text input does:

  • Checkbox — uses checked, and reads event.target.checked
  • Selectvalue goes on the <select>, not on an <option>
  • Textarea — takes value as a prop, not as its children
import { useState } from "react";

export default function App() {
  const [subscribed, setSubscribed] = useState(false);
  const [notes, setNotes] = useState("");

  return (
    <div style={{ fontFamily: "sans-serif", padding: 16, fontSize: 13 }}>
      <label>
        <input
          type="checkbox"
          checked={subscribed}
          onChange={(e) => setSubscribed(e.target.checked)}
        />{" "}
        Subscribe
      </label>

      <p>
        <textarea
          value={notes}
          onChange={(e) => setNotes(e.target.value)}
          placeholder="Notes"
          rows={3}
        />
      </p>

      <p>subscribed: {String(subscribed)}, notes: {notes.length} chars</p>
    </div>
  );
}

Submitting and validating

Put onSubmit on the <form> so the Enter key works, and call preventDefault so the browser does not reload the page — the point from Handling Events.

Validation is then ordinary JavaScript over your state. Showing errors only after a field has been touched keeps the form from shouting at someone who has not typed anything yet:

import { useState } from "react";

export default function App() {
  const [email, setEmail] = useState("");
  const [touched, setTouched] = useState(false);
  const [submitted, setSubmitted] = useState(null);

  const error = email.includes("@") ? null : "Enter a valid email address.";

  function handleSubmit(event) {
    event.preventDefault();
    setTouched(true);
    if (!error) setSubmitted(email);
  }

  return (
    <form onSubmit={handleSubmit} style={{ fontFamily: "sans-serif", padding: 16, fontSize: 13 }}>
      <input
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        onBlur={() => setTouched(true)}
        placeholder="you@example.com"
      />
      <button type="submit" style={{ marginLeft: 8 }}>Submit</button>

      {touched && error && <p style={{ color: "crimson" }}>{error}</p>}
      {submitted && <p style={{ color: "green" }}>Submitted: {submitted}</p>}
    </form>
  );
}

error is derived during render rather than stored in state — one fewer value that can fall out of step.

Common mistakes

  • value with no onChange. The input becomes read-only.
  • value={null} or undefined. React treats the input as uncontrolled and warns when a value arrives later. Start with "".
  • A handler per field. Use the name attribute and one handler.
  • Validation in state. Derive it from the field values instead.

Exercise

Three bugs: the input is read-only, the password field has its own redundant handler, and submitting reloads the page so nothing is ever logged. Fix all three — the form should show the submitted values.

import { useState } from "react";

export default function App() {
  const [form, setForm] = useState({ user: "", password: "" });

  function handleSubmit() {
    console.log("submitting", form);
  }

  return (
    <form onSubmit={handleSubmit} style={{ fontFamily: "sans-serif", padding: 16, fontSize: 13 }}>
      <p><input name="user" value={form.user} placeholder="Username" /></p>
      <p>
        <input
          name="password"
          type="password"
          value={form.password}
          onChange={(e) => setForm({ ...form, password: e.target.value })}
          placeholder="Password"
        />
      </p>
      <button type="submit">Sign in</button>
    </form>
  );
}