Handling Multiple Inputs

Real forms rarely have just one field — sign-up forms, checkout forms, and settings pages all ask for several pieces of data at once. Writing a separate useState call and a separate handler for every single input quickly becomes repetitive and hard to maintain. React’s idiomatic solution is to store all the related fields in a single state object and use one shared change handler that updates the correct key based on the input’s name attribute. This lesson shows you exactly how that pattern works, why it works, and the mistakes that trip people up.

Overview / How It Works

A form input in React is usually a controlled component: its displayed value is driven entirely by React state (value={state}), and every keystroke fires an onChange event that updates that state. When you have many fields, you don’t want ten different pieces of state and ten different handlers. Instead, you keep one object in state, where each key corresponds to one field:

const [formData, setFormData] = useState({
  name: "",
  email: "",
});

Then you give every <input> a name attribute that exactly matches its key in the state object, and point all of them at the same handleChange function. Inside that function, you read event.target.name and event.target.value to figure out which field changed and what it changed to, then use a computed property name ([name]: value) to update only that one key while spreading the rest of the object unchanged.

This works because of two JavaScript features you already know from this site’s JS course: object spread (...prevData) and computed property names ({ [name]: value }). React itself does nothing special here — the “multiple inputs” pattern is really just “one object in state, updated immutably, keyed by the DOM’s own name attribute.”

Remember that state must always be treated as immutable. You never assign directly into formData; you always call setFormData with a brand-new object. This matters for two reasons: React compares state by reference to decide whether to re-render, and mutating the existing object in place can silently break that comparison so your UI doesn’t update, or updates inconsistently on the next render.

Every time setFormData runs, React schedules a re-render of the component. React re-executes the function component, builds a new Virtual DOM tree, diffs it against the previous tree (reconciliation), and commits only the minimal set of real DOM changes — in this case, updating the value of the one input whose backing state actually changed. The other inputs’ DOM nodes are left untouched because their part of the tree is unchanged after the diff.

Syntax

const [formData, setFormData] = useState({ field1: "", field2: "" });

function handleChange(event) {
  const { name, value } = event.target;
  setFormData((prevData) => ({
    ...prevData,
    [name]: value,
  }));
}

<input name="field1" value={formData.field1} onChange={handleChange} />
<input name="field2" value={formData.field2} onChange={handleChange} />
Part Purpose
formData A single state object holding the current value of every field.
name attribute Identifies which key in formData this input controls. Must match the object key exactly.
event.target.name Read inside the handler to know which field triggered the change.
event.target.value The new value typed by the user (for checkboxes, use event.target.checked instead).
[name]: value A computed property name — writes into whichever key name currently holds.
...prevData Spreads the previous state so untouched fields are preserved.

Examples

Example 1: A Basic Two-Field Form

import { useState } from "react";

function ContactForm() {
  const [formData, setFormData] = useState({
    name: "",
    email: "",
  });

  function handleChange(event) {
    const { name, value } = event.target;
    setFormData((prevData) => ({
      ...prevData,
      [name]: value,
    }));
  }

  function handleSubmit(event) {
    event.preventDefault();
    console.log(formData);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="name">Name</label>
      <input
        id="name"
        name="name"
        type="text"
        value={formData.name}
        onChange={handleChange}
      />

      <label htmlFor="email">Email</label>
      <input
        id="email"
        name="email"
        type="email"
        value={formData.email}
        onChange={handleChange}
      />

      <button type="submit">Submit</button>
    </form>
  );
}

export default ContactForm;

Output:

{ name: "Ada Lovelace", email: "ada@example.com" }

Both inputs share the exact same handleChange function. Because each input carries its own name, the handler knows exactly which key of formData to update, and the other field is preserved untouched by the spread. Submitting the form logs the whole object.

Example 2: Mixing Text, Select, and Checkbox Inputs

import { useState } from "react";

function PreferencesForm() {
  const [formData, setFormData] = useState({
    username: "",
    plan: "free",
    newsletter: false,
  });

  function handleChange(event) {
    const { name, value, type, checked } = event.target;
    setFormData((prevData) => ({
      ...prevData,
      [name]: type === "checkbox" ? checked : value,
    }));
  }

  return (
    <form>
      <label htmlFor="username">Username</label>
      <input
        id="username"
        name="username"
        type="text"
        value={formData.username}
        onChange={handleChange}
      />

      <label htmlFor="plan">Plan</label>
      <select id="plan" name="plan" value={formData.plan} onChange={handleChange}>
        <option value="free">Free</option>
        <option value="pro">Pro</option>
        <option value="team">Team</option>
      </select>

      <label htmlFor="newsletter">
        <input
          id="newsletter"
          name="newsletter"
          type="checkbox"
          checked={formData.newsletter}
          onChange={handleChange}
        />
        Subscribe to newsletter
      </label>

      <p>
        Preview: {formData.username || "(no name)"} — {formData.plan} plan —{" "}
        {formData.newsletter ? "subscribed" : "not subscribed"}
      </p>
    </form>
  );
}

export default PreferencesForm;

This renders a username field, a plan dropdown, a newsletter checkbox, and a live preview paragraph that updates on every keystroke, selection, or check. The key detail is the ternary inside handleChange: a checkbox’s meaningful value lives in event.target.checked (a boolean), not event.target.value (which is always the fixed string "on" for checkboxes). Branching on event.target.type lets one handler correctly serve text inputs, selects, and checkboxes at once.

Example 3: A Realistic Signup Form With Validation

import { useState } from "react";

function SignupForm() {
  const [formData, setFormData] = useState({
    email: "",
    password: "",
    confirmPassword: "",
  });
  const [error, setError] = useState("");

  function handleChange(event) {
    const { name, value } = event.target;
    setFormData((prevData) => ({
      ...prevData,
      [name]: value,
    }));
  }

  function handleSubmit(event) {
    event.preventDefault();

    if (formData.password !== formData.confirmPassword) {
      setError("Passwords do not match.");
      return;
    }

    setError("");
    console.log("Signing up with:", formData);
    setFormData({ email: "", password: "", confirmPassword: "" });
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        name="email"
        type="email"
        value={formData.email}
        onChange={handleChange}
        required
      />

      <label htmlFor="password">Password</label>
      <input
        id="password"
        name="password"
        type="password"
        value={formData.password}
        onChange={handleChange}
        required
      />

      <label htmlFor="confirmPassword">Confirm Password</label>
      <input
        id="confirmPassword"
        name="confirmPassword"
        type="password"
        value={formData.confirmPassword}
        onChange={handleChange}
        required
      />

      {error && <p>{error}</p>}

      <button type="submit">Sign Up</button>
    </form>
  );
}

export default SignupForm;

This form adds a third field and a second piece of state (error) that is separate from the field data because it isn’t a form field itself — it’s derived, transient UI feedback. On submit, it compares password and confirmPassword, shows an error message if they differ, and otherwise logs the data and resets every field back to an empty string by calling setFormData with a brand-new object.

How It Works Step by Step

On mount: useState initializes formData with the given defaults, and React renders each input with its value pulled from that object.

On every keystroke: the browser fires a native change (or input, wrapped by React) event, React wraps it in a SyntheticEvent, and your handleChange runs synchronously with event.target pointing at the DOM node the user interacted with. You read name and value off that node.

State update: calling setFormData with an updater function ((prevData) => ({ ...prevData, [name]: value })) queues a re-render. Using the updater form rather than referencing the outer formData directly guards against stale values if multiple updates are batched together.

Re-render and reconciliation: React re-invokes the component function, produces a new element tree, and diffs it against the previous one. Only the one <input> whose value prop actually changed gets its real DOM node updated — the rest are structurally identical and are left alone.

On submit: your handler calls event.preventDefault() to stop the browser’s default full-page reload, then does whatever you need with the current formData — validate it, send it to an API, or reset it with another setFormData call.

Common Mistakes

Mistake 1: Mutating the state object directly

function handleChange(event) {
  const { name, value } = event.target;
  formData[name] = value; // mutates the existing object
  setFormData(formData);  // same reference — React may skip the re-render
}

React decides whether to re-render partly by checking if the new state is a different reference from the old one. Assigning directly onto formData and passing that same object back to setFormData can result in inconsistent UI, since React has no reliable signal that anything changed. Always build a new object instead:

function handleChange(event) {
  const { name, value } = event.target;
  setFormData((prevData) => ({ ...prevData, [name]: value }));
}

Mistake 2: Hardcoding the key instead of using the input’s name

function handleChange(event) {
  setFormData({ ...formData, name: event.target.value });
}

<input name="email" onChange={handleChange} value={formData.email} />
<input name="password" onChange={handleChange} value={formData.password} />

This handler always writes into the literal key "name", no matter which input fired the event — so typing in the email or password field silently does nothing useful, or worse, creates an unrelated name key on the object. The fix is the computed property name driven by event.target.name:

function handleChange(event) {
  const { name, value } = event.target;
  setFormData((prevData) => ({ ...prevData, [name]: value }));
}

Mistake 3: Using value instead of checked for checkboxes

<input
  name="newsletter"
  type="checkbox"
  value={formData.newsletter}
  onChange={handleChange}
/>

A checkbox’s ticked/unticked state is controlled by the checked prop, not value. Passing a boolean into value leaves the checkbox uncontrolled by React and can trigger a console warning about switching between controlled and uncontrolled inputs. Use checked={formData.newsletter} and read event.target.checked in the handler, as shown in Example 2.

Best Practices

  • Keep every name attribute in exact sync with its state key — a typo here silently breaks that one field.
  • Always update state immutably with spread syntax, never by assigning into the existing object or array.
  • Branch on event.target.type inside a shared handler so checkboxes (checked) and text/select inputs (value) are both handled correctly.
  • Use the updater-function form of setState ((prev) => ({ ...prev, ... })) when the new value depends on the previous state, to avoid stale-closure bugs.
  • For forms with many interdependent fields or complex validation, consider useReducer instead of several useState calls — it centralizes the update logic in one reducer function.
  • Reset a form by calling setFormData with a fresh object matching your initial shape, rather than manually clearing each input.
  • Extract a reusable labeled-input component if your form has many similar fields, to avoid repeating the same JSX structure.

Practice Exercises

  • Build a ProfileForm component with name, email, and bio (a <textarea>) fields, all driven by a single formData state object and one shared handleChange function.
  • Add a “Subscribe to newsletter” checkbox to the form above. Make sure it reads and writes event.target.checked correctly and that the preview text updates live.
  • Rewrite the SignupForm from Example 3 to manage its fields with useReducer instead of useState, using an action shaped like { type: "field", name, value }.

Summary

  • Store related form fields in a single state object rather than one useState per field.
  • Give every input a name attribute matching its key, and use one handleChange function for all of them.
  • Read event.target.name and event.target.value and update state immutably with a computed property name and spread syntax.
  • Checkboxes use checked and event.target.checked, not value.
  • Every setFormData call triggers React’s render → reconcile → commit cycle, updating only the DOM nodes that actually changed.
  • Never mutate state directly — always produce a new object so React can reliably detect the change.