React – Forms and Controlled Inputs

July 9, 20265 min readUpdated 8/18/2026

In plain HTML an input owns its own value and you go and read it when you need it. In React you normally invert that: state owns the value, the input displays it. That is a controlled input, and it is what makes live validation, formatting, disabled submit buttons and a live price preview possible at all.

The pattern

Two props, always together: value and onChange.

const [email, setEmail] = useState('customer@pizza.test');

<Form.Control
  id={`${formId}-email`}
  type="email"
  required
  value={email}
  onChange={(e) => setEmail(e.target.value)}
  autoComplete="username"
/>

Each keystroke fires onChange, which sets state, which re-renders, which puts the new value back in the input. It sounds like a lot of work per character and it is not — this is what React is fast at.

Give value without onChange and the field is read-only. The input is pinned to a value nothing ever changes, so typing does nothing. React warns about this in development. If you genuinely want an uncontrolled input with a starting value, the prop is defaultValue.

Never initialise state to undefined or null for a text input. React treats a value of undefined as uncontrolled and then switches to controlled on the first keystroke, which produces the warning "A component is changing an uncontrolled input to be controlled". Start with ''.

The whole form

Here is a real one end to end — the sign-in page:

export function LoginPage() {
  const { login, error, loading } = useAuth();
  const navigate = useNavigate();
  const formId = useId();

  const [email, setEmail] = useState('customer@pizza.test');
  const [password, setPassword] = useState('pizza123');

  async function handleSubmit(event: React.FormEvent) {
    event.preventDefault();
    try {
      await login(email, password);
      navigate(redirectTo, { replace: true });
    } catch {
      // The error message is already surfaced through AuthContext; nothing to do here.
    }
  }

  return (
    <Form onSubmit={handleSubmit}>
      {error && <Alert variant="danger">{error}</Alert>}

      <Form.Group className="mb-3">
        <Form.Label htmlFor={`${formId}-email`}>Email</Form.Label>
        <Form.Control
          id={`${formId}-email`}
          type="email"
          required
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          autoComplete="username"
        />
      </Form.Group>

      <Form.Group className="mb-3">
        <Form.Label htmlFor={`${formId}-password`}>Password</Form.Label>
        <Form.Control
          id={`${formId}-password`}
          type="password"
          required
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          autoComplete="current-password"
        />
      </Form.Group>

      <Button type="submit" variant="primary" className="w-100" disabled={loading}>
        {loading ? 'Signing in…' : 'Sign in'}
      </Button>
    </Form>
  );
}

Five things in there are worth calling out.

onSubmit on the form, not onClick on the button. Pressing Enter in a text field submits a form without clicking anything, so an onClick handler silently excludes keyboard users. The button needs type="submit" for the same reason.

event.preventDefault(). Without it the browser does a full page navigation and your application restarts from scratch. See Handling Events.

disabled={loading}. This is the payoff of controlled state — the button knows about the in-flight request, so a slow network cannot produce three sign-in attempts.

required and type="email" are still there. Controlled inputs do not replace the browser's own validation; use both.

autoComplete is set properly. username and current-password are what password managers look for.

useId, for labels

A <label> needs an htmlFor matching the input's id, or clicking the label does not focus the field and screen readers cannot announce it. But a hardcoded id="email" breaks the moment two of that component render on one page.

useId generates a stable unique prefix:

const formId = useId();

<Form.Label htmlFor={`${formId}-email`}>Email</Form.Label>
<Form.Control id={`${formId}-email`} /* … */ />

One useId per component, suffixed per field — that is the intended usage. It is stable across re-renders and consistent between server and client rendering, which Math.random() is not.

Do not use it for list keys. That is a different problem with a different answer: Rendering Lists and Keys.

Many fields, one handler

Separate useState calls are fine up to about four fields. Past that, one object and a generic handler is less repetitive:

const [form, setForm] = useState({ street: '', city: '', state: '', zip: '' });

function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
  const { name, value } = e.target;
  setForm((current) => ({ ...current, [name]: value }));
}

<Form.Control name="street" value={form.street} onChange={handleChange} />
<Form.Control name="city" value={form.city} onChange={handleChange} />
<Form.Control name="zip" value={form.zip} onChange={handleChange} />

[name]: value is a computed property key — the field's name attribute selects which key to update. And note the updater function: the new state depends on the old, so the updater form is the correct choice.

The other input types

Checkbox

Checkboxes use checked, not value, and read e.target.checked:

const [saveCard, setSaveCard] = useState(false);

<Form.Check
  type="checkbox"
  id="save-card"
  label="Save this card for next time"
  checked={saveCard}
  onChange={(e) => setSaveCard(e.target.checked)}
/>

Radio

Several inputs share one name; each is checked when the state matches its own value:

{crusts.map((option) => (
  <Col key={option.id}>
    <Form.Check
      type="radio"
      name="crust"
      id={`crust-${option.id}`}
      checked={crustId === option.id}
      onChange={() => setCrustId(option.id)}
      label={<span>{option.name}</span>}
    />
  </Col>
))}

Select

A select is controlled on the <select>, never on the options. Note the Number(…) — every DOM value is a string, so a numeric field needs converting or you get "2" where you wanted 2:

<Form.Select
  id={quantityId}
  value={quantity}
  onChange={(e) => setQuantity(Number(e.target.value))}
  style={{ maxWidth: '8rem' }}
>
  {[1, 2, 3, 4, 5].map((n) => (
    <option key={n} value={n}>
      {n}
    </option>
  ))}
</Form.Select>

Textarea

In HTML the content sits between the tags. In React it is a value prop like everything else:

<Form.Control as="textarea" rows={3} value={notes} onChange={(e) => setNotes(e.target.value)} />

Multi-select toggles

Toppings are not a form control at all — they are buttons backed by an array of ids, which is often the nicer interface:

const [selectedToppingIds, setSelectedToppingIds] = useState<string[]>([]);

function toggleTopping(id: string) {
  setSelectedToppingIds((current) =>
    current.includes(id) ? current.filter((t) => t !== id) : [...current, id],
  );
}

<Button
  type="button"
  size="sm"
  variant={selected ? 'primary' : 'outline-secondary'}
  aria-pressed={selected}
  onClick={() => toggleTopping(topping.id)}
>
  {topping.name}
  <span className="ms-1 small opacity-75">+{formatMoney(topping.price)}</span>
</Button>

type="button" is not optional. A <button> inside a form defaults to type="submit", so without it every topping click would submit the form. aria-pressed is what tells a screen reader this is a toggle rather than an action.

What controlled state buys you

Because every choice in the builder is in state, the price can be derived from it and shown live as the user clicks:

const pricing = useMemo(() => {
  if (!product) return { unit: 0, total: 0 };

  const base = product.sizes.find((s) => s.size === size)?.price ?? 0;
  const toppingsTotal = selectedToppings.reduce((sum, t) => sum + t.price, 0);
  const unit = round2(base + (crust?.priceDelta ?? 0) + toppingsTotal);

  return { unit, total: round2(unit * quantity) };
}, [product, size, crust, selectedToppings, quantity]);

With uncontrolled inputs you would be reading the DOM on every change to do that. This is the argument for controlled inputs in one example.

When not to control

Two cases. A file input cannot be controlled — its value is read-only for security reasons, so use a ref. And once a form is genuinely large, re-rendering the whole thing on every keystroke starts to cost something; that is when people reach for React Hook Form, which keeps inputs uncontrolled and subscribes to them individually.

Neither applies to most forms. Start controlled.

Next

Passing Data Deeply with Context.