Controlled Components

A controlled component is a form element (like an input, textarea, or select) whose value is driven entirely by React state, rather than by the DOM itself. Instead of reading the current value out of the input when you need it, you keep the value in state at all times and the input simply displays whatever that state holds. This gives React — not the browser — full control over the form data, which is why the pattern is called “controlled.”

Controlled components are the default, recommended way to build forms in React because they make the current form data a single source of truth that lives in your component, ready to be validated, transformed, disabled, or submitted at any moment.

Overview / How it works

In plain HTML, form elements keep their own internal state. An <input> remembers what the user typed, and you only find out its value by querying the DOM (for example with document.querySelector or a form submit event). React’s philosophy is declarative UI: the UI should always be a function of state. Controlled components apply that philosophy to forms by making the state the source of truth and the DOM node just a reflection of it.

To make an input controlled, you do two things together:

  • Set the input’s value prop to a piece of state, e.g. value={name}.
  • Attach an onChange handler that calls the state setter with the new value the user typed, e.g. onChange={(e) => setName(e.target.value)}.

This creates a tight loop: the user types a character, the browser fires an onChange event, your handler reads e.target.value and calls setName(...), React schedules a re-render, and the component re-renders with the input’s value prop set to the new state. Visually nothing looks different to the user (the character they typed simply appears), but structurally the displayed value came from React state on every single keystroke, not from the browser remembering what was typed.

If you set value on an input but do not provide an onChange handler, React will render the input as read-only and log a console warning, because the value can never change — the user could type, but since no state update happens, the input would appear “stuck.” This is a deliberate safeguard: it stops you from accidentally creating an input that looks editable but silently ignores keystrokes.

Because the value always comes from state, controlled components let you do things that are otherwise awkward: reject or transform certain characters as they’re typed, force uppercase, cap a field’s length, keep two fields in sync, or disable the submit button until every field is valid — all just by deciding what to pass to setState inside onChange.

Syntax

const [value, setValue] = useState("");

<input
  type="text"
  value={value}
  onChange={(e) => setValue(e.target.value)}
/>
Part Purpose
useState("") Creates the state that will act as the single source of truth for this field’s current value.
value={value} Binds the input’s displayed value to state. React re-renders the DOM node to always match this.
onChange Fires on every keystroke (or change) and receives a SyntheticEvent; e.target.value is the new text the browser would have set.
setValue(...) Updates state with the new value, triggering a re-render so the input reflects the latest keystroke.

The same pattern applies to other elements, with small differences in which attribute holds the value: <textarea value={text} onChange={...}>, <select value={choice} onChange={...}>, and for checkboxes/radios you bind checked instead of value: <input type="checkbox" checked={isChecked} onChange={(e) => setIsChecked(e.target.checked)} />.

Examples

Example 1: A single controlled text input

import { useState } from "react";

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

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

export default NameInput;

Renders: a text box and a greeting paragraph that updates on every keystroke, e.g. typing “Ada” makes the paragraph read “Hello, Ada!” in real time, and shows “Hello, stranger!” when the field is empty.

This is the simplest possible controlled component. Every keystroke triggers onChange, which updates name, which causes a re-render, which passes the new name back into the input’s value and into the greeting. Because state, not the DOM, holds the truth, the greeting can never drift out of sync with what’s in the box.

Example 2: A full form with multiple fields and validation

import { useState } from "react";

function SignupForm() {
  const [formData, setFormData] = useState({ email: "", password: "" });
  const [submitted, setSubmitted] = useState(null);

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

  function handleSubmit(e) {
    e.preventDefault();
    if (formData.password.length < 8) {
      setSubmitted("Password must be at least 8 characters.");
      return;
    }
    setSubmitted(`Account requested for ${formData.email}`);
  }

  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
      />

      <button type="submit">Sign up</button>
      {submitted && <p>{submitted}</p>}
    </form>
  );
}

export default SignupForm;

Renders: an email field, a password field, and a submit button. Submitting with a short password shows “Password must be at least 8 characters.”; submitting with a valid one shows “Account requested for name@example.com”.

This example shows one onChange handler shared by two inputs using each input’s name attribute to update only the matching key in a single state object, spreading the previous state (...prev) so the other field is preserved. Validation runs on submit, reading straight from state — no need to query the DOM at all.

Example 3: A controlled select and checkbox together

import { useState } from "react";

function PreferencesForm() {
  const [plan, setPlan] = useState("free");
  const [subscribed, setSubscribed] = useState(true);

  return (
    <form>
      <label htmlFor="plan">Plan</label>
      <select id="plan" value={plan} onChange={(e) => setPlan(e.target.value)}>
        <option value="free">Free</option>
        <option value="pro">Pro</option>
        <option value="team">Team</option>
      </select>

      <label>
        <input
          type="checkbox"
          checked={subscribed}
          onChange={(e) => setSubscribed(e.target.checked)}
        />
        Email me updates
      </label>

      <p>
        Selected: {plan}, subscribed: {subscribed ? "yes" : "no"}
      </p>
    </form>
  );
}

export default PreferencesForm;

Renders: a dropdown defaulting to “Free” and a checked checkbox labeled “Email me updates”, with a summary paragraph like “Selected: pro, subscribed: no” that updates live as the user changes either control.

The <select> is controlled the same way as a text input (via value/onChange), while the checkbox uses checked/onChange instead of value, since a checkbox’s meaningful state is boolean, not text.

How it works step by step

  • On mount: React renders the component, reads the initial state (e.g. useState("")), and sets the input’s DOM value attribute to match it.
  • On keystroke: the browser fires a native change/input event, which React’s synthetic event system delivers to your onChange handler as a SyntheticEvent. Your handler calls a state setter with the new value.
  • Re-render: React schedules a re-render of the component. During this render, the JSX evaluates value={state} again using the freshly updated state.
  • Commit: React reconciles the new virtual DOM against the previous one and, seeing the same input element, updates only its value attribute in the real DOM to match — which, since it matches what the user just typed, appears seamless.
  • On unmount: the component and its state are discarded; there is nothing left in the DOM for React to reconcile against.

Because every keystroke goes through this render cycle, controlled inputs give you a guaranteed hook (the onChange handler) to intercept, transform, or reject changes before they ever reach the screen.

Common Mistakes

Mistake 1: Setting value without onChange

// Wrong: input becomes unresponsive, React warns in the console
function Broken() {
  const  = useState("");
  return <input value={text} />;
}

Without onChange, the state never updates, so React keeps forcing the DOM value back to the original string on every render, making the field feel frozen. Always pair value with an onChange handler that updates the same state.

function Fixed() {
  const  = useState("");
  return <input value={text} onChange={(e) => setText(e.target.value)} />;
}

Mistake 2: Mutating state directly instead of creating a new object

// Wrong: mutates the existing state object, React may not detect a change
function handleChange(e) {
  formData[e.target.name] = e.target.value;
  setFormData(formData);
}

Mutating an object in place and passing the same reference back to the setter can cause React to skip the re-render, because it compares references. Always build a new object.

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

Mistake 3: Forgetting e.preventDefault() on submit

Without calling e.preventDefault() inside your onSubmit handler, the browser performs its default full-page navigation/reload on submit, wiping out your component’s state. Always call it as the first line of the handler when you intend to manage submission with JavaScript.

Best Practices

  • Initialize state to a sensible empty value ("" for text, false for checkboxes) so the input is never undefined, which React treats as “uncontrolled” and warns about if it later becomes a defined value.
  • For multi-field forms, keep one state object keyed by field name and reuse a single onChange handler rather than one state variable and one handler per field.
  • Always spread the previous state ({ ...prev, [name]: value }) when updating one field of an object, so sibling fields aren’t lost.
  • Validate on submit for final checks, but you can also validate per-keystroke inside onChange if you want instant feedback — just keep the logic fast since it runs on every character.
  • Use checked (not value) for checkboxes and radio buttons, since their meaningful value is boolean/selected state.
  • Reach for an uncontrolled input with a ref only when you truly don’t need to react to every keystroke (e.g. a one-time file upload); otherwise controlled components are the safer default.

Practice Exercises

  • Build a controlled <textarea> that shows a live character count below it, and turns the count red once it exceeds 140 characters.
  • Build a two-field form (“password” and “confirm password”) that disables the submit button unless both fields are non-empty and equal to each other.
  • Build a controlled radio-button group for choosing a shipping speed (“standard”, “express”, “overnight”) and display the selected value and an estimated price that updates as the user picks a different option.

Summary

  • A controlled component’s form value is stored in React state and passed into the element via value (or checked for checkboxes/radios).
  • An onChange handler reads e.target.value (or e.target.checked) and updates that state on every change, closing the loop.
  • Because state is the single source of truth, you can validate, transform, or react to form data at any time without touching the DOM directly.
  • Setting value without onChange creates a read-only, “frozen” input and triggers a React warning.
  • Always update object state immutably (spread the previous state) so React reliably detects the change and re-renders.