← React for Beginners

Forms and controlled inputs

Lesson 08 of 12 · 9 min

The <input> in the last lesson was doing its own thing. You typed, the DOM held the text, and React had no idea what was in it. That works right up until you need the value - to validate it, to show a character count, to clear the box, or to send it anywhere.

A controlled input

Two things make an input controlled: you set its value from state, and you update that state on every keystroke.

import { useState } from "react";

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

  return (
    <>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <p>Hello, {name || "stranger"}.</p>
    </>
  );
}

Type, and the paragraph updates as you go. There is a loop here worth tracing once, because every form you write afterwards is a variation on it:

  1. You press a key.
  2. onChange fires. e.target.value is what the input would now contain.
  3. setName stores it and schedules a render.
  4. The component runs again, and value={name} puts that text in the box.

The character you see is not the one the browser put there. It is the one React put back. The input is now a display of state, exactly like the paragraph beneath it.

e is the event object, the same kind the DOM has always given you, and e.target is the element that fired it. React normalises these across browsers, so e.target.value is reliable everywhere.

Why bother

Because state is now the single source of truth, and the rest of what you want from a form falls out of that:

<input value={name} onChange={(e) => setName(e.target.value.slice(0, 20))} />
<p>{name.length}/20</p>
<button onClick={() => setName("")}>Clear</button>
<button disabled={name.trim() === ""}>Save</button>

A length cap, a live counter, a clear button, and a submit that disables itself when the field is empty - and not one of them needed to reach into the DOM. This is the same argument as lesson 1, now on something you can type into.

Note e.target.value.slice(0, 20): because React puts the value back, whatever you store is what appears. Refuse to store the 21st character and the 21st character never shows up.

A form with several fields

One useState per field works, and gets tedious fast. Keep the whole form in one object:

function SignupForm() {
  const [form, setForm] = useState({ name: "", email: "", newsletter: false });

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

  return (
    <form>
      <input name="name" value={form.name} onChange={handleChange} />
      <input name="email" type="email" value={form.email} onChange={handleChange} />
      <label>
        <input
          name="newsletter"
          type="checkbox"
          checked={form.newsletter}
          onChange={handleChange}
        />
        Send me the newsletter
      </label>
    </form>
  );
}

One handler for the whole form. Three details are carrying it:

The name attribute is doing real work. It is a plain HTML attribute, and it is how the handler knows which field fired. Give every input a name matching its key in state.

[name] in the object literal is a computed property name. Square brackets around a key mean “evaluate this expression and use the result as the key”. So if name is "email", that line writes to form.email. Without the brackets you would create a key literally called name.

Checkboxes are different in two ways. They use checked rather than value, and the value you want is e.target.checked, not e.target.value - which for a checkbox is the mostly-useless string "on". Hence the type === "checkbox" branch.

And the spread is not optional. setForm({ ...form, [name]: value }) builds a new object carrying the other fields across. Writing form[name] = value would modify the object React is already holding, and by lesson 7’s rule nothing would re-render.

Submitting

A <form> still does what forms have always done: press Enter or click a submit button and the browser navigates. In a React app that reloads everything and throws your state away.

function handleSubmit(e) {
  e.preventDefault();
  console.log("submitting", form);
  setForm({ name: "", email: "", newsletter: false });
}

return (
  <form onSubmit={handleSubmit}>
    {/* fields */}
    <button type="submit">Sign up</button>
  </form>
);

onSubmit goes on the <form>, not the button, and e.preventDefault() is what stops the navigation. Put the handler on the button’s onClick instead and you lose Enter-to-submit, which is how a lot of people fill in forms.

Two smaller things worth knowing:

  • A <button> inside a form defaults to type="submit". If you have a button that does something else - “Add another”, say - give it type="button" or it will submit the form every time.
  • Resetting the form is just setting the state back. There is no separate reset mechanism because there is no separate place the values live.

The one warning you will meet

Start a field as undefined and React will tell you off:

const [form, setForm] = useState({});
<input value={form.name} onChange={handleChange} />
A component is changing an uncontrolled input to be controlled.

form.name is undefined on the first render, so React treats the input as uncontrolled; once you type, it becomes a string and suddenly it is controlled. The fix is to give every field a real initial value - usually "" - in the object you pass to useState. This is why the example above lists all three fields up front rather than starting from {}.

Recap

A controlled input takes its value from state and writes back on every onChange, so state is the only place the value lives. Once that is true, validation, counters, clearing and disabling are ordinary reads of a variable. One state object plus a name attribute and a computed key [name] handles a whole form with one handler. Checkboxes use checked. Submit on the <form>, call preventDefault, and initialise every field so React never sees undefined where a string should be.

Next: what to do when two components need the same piece of state.