Forms and controlled inputs

Published:

An <input> is already stateful in the browser. The user can type into it, and the DOM node remembers its current value even if React does nothing special. That leaves the next hole:

if input elements keep their own DOM state, how does React keep form data in component state instead?

The browser remembers #

Start with the plainest input:

function SignupForm() {
  return (
    <label>
      Name
      <input name="name" />
    </label>
  );
}

root.render(<SignupForm />);
setTimeout(() => {
  const input = mountNode.querySelector('input');
  input.value = 'Maya';
  input.dispatchEvent(new Event('input', { bubbles: true }));
  console.log('DOM value:', input.value);
}, 20);

Waiting to run

Not run yet.

The value changed, even though the component has no state.

That is normal browser behavior. The DOM node owns the current text. React rendered the initial input element, and then the user can edit the DOM state directly.

This is not wrong. It is just a different state model from the one we have been building. If the component wants to show a live preview, enable a button, or keep several fields consistent, the value has to be available during render.

So the next question is:

how does the value the user typed become state that the component can render from?

Reading the change #

We already know how events bring information back from the browser. For text inputs, React uses onChange, which fires when the user changes the value.[1]

function SignupForm() {
  function handleChange(event) {
    console.log('typed:', event.currentTarget.value);
  }

  return (
    <label>
      Name
      <input name="name" onChange={handleChange} />
    </label>
  );
}

root.render(<SignupForm />);
setTimeout(() => {
  const input = mountNode.querySelector('input');
  const valueSetter = Object.getOwnPropertyDescriptor(
    HTMLInputElement.prototype,
    'value',
  ).set;
  valueSetter.call(input, 'Maya');
  input.dispatchEvent(new Event('input', { bubbles: true }));
}, 20);

Waiting to run

Not run yet.

Now the component hears about the change. The event object points at the input that changed. Its current DOM value is available as event.currentTarget.value. But this still only logs. The value enters a handler and then disappears. To make it part of the next render, the handler has to call a state setter.

Copying into state #

The smallest bridge from the input to React state is:

const { useState } = React;

function SignupForm() {
  const [name, setName] = useState('');

  function handleChange(event) {
    setName(event.currentTarget.value);
  }

  return (
    <div>
      <label>
        Name
        <input name="name" onChange={handleChange} />
      </label>
      <p>Preview: {name}</p>
    </div>
  );
}

root.render(<SignupForm />);
setTimeout(() => {
  const input = mountNode.querySelector('input');
  const valueSetter = Object.getOwnPropertyDescriptor(
    HTMLInputElement.prototype,
    'value',
  ).set;
  valueSetter.call(input, 'Maya');
  input.dispatchEvent(new Event('input', { bubbles: true }));
  setTimeout(() => {
    console.log(mountNode.textContent);
  }, 0);
}, 20);

Waiting to run

Not run yet.

The preview changes because the input event updated state, and state caused another render.

The data path is:

  1. the browser input value changes
  2. React calls onChange
  3. the handler reads event.currentTarget.value
  4. the handler calls setName(...)
  5. the next render reads name

That is enough for the preview. But the input itself is still owned by the DOM. React is observing its changes, not controlling its displayed value.

Writing from state #

To make React the source of truth, the rendered input must receive its current value from state:

const { useState } = React;

function SignupForm() {
  const [name, setName] = useState('');

  function handleChange(event) {
    setName(event.currentTarget.value);
  }

  return (
    <div>
      <label>
        Name
        <input
          name="name"
          value={name}
          onChange={handleChange}
        />
      </label>
      <p>Preview: {name}</p>
    </div>
  );
}

root.render(<SignupForm />);
setTimeout(() => {
  const input = mountNode.querySelector('input');
  const valueSetter = Object.getOwnPropertyDescriptor(
    HTMLInputElement.prototype,
    'value',
  ).set;
  valueSetter.call(input, 'Maya');
  input.dispatchEvent(new Event('input', { bubbles: true }));
  setTimeout(() => {
    console.log('input value:', input.value);
    console.log('page text:', mountNode.textContent);
  }, 0);
}, 20);

Waiting to run

Not run yet.

Now the loop is closed.

The input displays name because React passes value={name} during render. The user edits the input, onChange reports the next value, and the handler stores that value in state. The next render sends the state value back into the input.

That is a controlled input:

a controlled text input receives its displayed value from React state and reports edits through onChange

The value prop is the value for this render.[1]

A frozen input #

The loop has two sides. If we give the input a value but do not update the state behind that value, the user has nowhere to put new text.

function SignupForm() {
  return (
    <label>
      Name
      <input value="Maya" onChange={() => {
        console.log('change heard, but value stayed fixed');
      }} />
    </label>
  );
}

root.render(<SignupForm />);
setTimeout(() => {
  const input = mountNode.querySelector('input');
  const valueSetter = Object.getOwnPropertyDescriptor(
    HTMLInputElement.prototype,
    'value',
  ).set;
  valueSetter.call(input, 'Maya K.');
  input.dispatchEvent(new Event('input', { bubbles: true }));
  setTimeout(() => {
    console.log('after render:', input.value);
  }, 0);
}, 20);

Waiting to run

Not run yet.

React keeps rendering value="Maya". The handler runs, but it does not store the new value anywhere. The next render still says the input value is Maya.

So the controlled-input rule is stricter than "add a value prop":

every editable controlled input needs an onChange handler that updates the state used by its value

If the value should be fixed on purpose, make that clear with readOnly. If the value should only be an initial value and the DOM may own later edits, use defaultValue instead.

Initial value #

defaultValue gives the browser an initial text value without making React control every later edit.[1]

function SignupForm() {
  return (
    <label>
      Name
      <input defaultValue="Maya" />
    </label>
  );
}

root.render(<SignupForm />);
setTimeout(() => {
  const input = mountNode.querySelector('input');
  console.log('initial:', input.value);
  input.value = 'Maya K.';
  input.dispatchEvent(new Event('input', { bubbles: true }));
  console.log('after edit:', input.value);
}, 20);

Waiting to run

Not run yet.

This input is uncontrolled after the first render. React supplied the starting value; the DOM owns the current value after that.

So there are two different props with different meanings:

function SignupForm() {
  return <input defaultValue="Maya" />;
}

root.render(<SignupForm />);
setTimeout(() => {
  const input = mountNode.querySelector('input');
  console.log('initial DOM value:', input.value);
}, 20);

Waiting to run

Not run yet.

means "start the DOM input with this value."

const { useState } = React;

function SignupForm() {
  const [name, setName] = useState('Maya');

  function handleChange(event) {
    setName(event.currentTarget.value);
  }

  return <input value={name} onChange={handleChange} />;
}

root.render(<SignupForm />);
setTimeout(() => {
  const input = mountNode.querySelector('input');
  console.log('controlled DOM value:', input.value);
}, 20);

Waiting to run

Not run yet.

means "display the React state value, and update that state when the user edits."

Checkboxes #

Text inputs control their text with value. Checkboxes control their selected state with checked.[1]

const { useState } = React;

function PreferencesForm() {
  const [wantsEmail, setWantsEmail] = useState(false);

  function handleChange(event) {
    setWantsEmail(event.currentTarget.checked);
  }

  return (
    <div>
      <label>
        <input
          type="checkbox"
          checked={wantsEmail}
          onChange={handleChange}
        />
        Email me updates
      </label>
      <p>Status: {wantsEmail ? 'subscribed' : 'not subscribed'}</p>
    </div>
  );
}

root.render(<PreferencesForm />);
setTimeout(() => {
  const checkbox = mountNode.querySelector('input');
  checkbox.click();
  setTimeout(() => {
    console.log(mountNode.textContent);
  }, 0);
}, 20);

Waiting to run

Not run yet.

The shape is the same as the text input, but the property changes:

const { useState } = React;

function PreferencesForm() {
  const [wantsEmail, setWantsEmail] = useState(false);

  return (
    <input
      type="checkbox"
      checked={wantsEmail}
      onChange={(event) => setWantsEmail(event.currentTarget.checked)}
    />
  );
}

root.render(<PreferencesForm />);
setTimeout(() => {
  const checkbox = mountNode.querySelector('input');
  checkbox.click();
  setTimeout(() => {
    console.log('checked:', checkbox.checked);
  }, 0);
}, 20);

Waiting to run

Not run yet.

For a checkbox, event.currentTarget.value is not the selected boolean. The selected boolean is event.currentTarget.checked.

So the rule has to follow the kind of control:

  • text input: value and event.currentTarget.value
  • checkbox: checked and event.currentTarget.checked

Textareas #

React keeps <textarea> and <select> in the same controlled-input shape.

A textarea receives its text from value and updates through onChange.[2]

const { useState } = React;

function BioForm() {
  const [bio, setBio] = useState('Writes notes.');

  return (
    <label>
      Bio
      <textarea
        value={bio}
        onChange={(event) => setBio(event.currentTarget.value)}
      />
    </label>
  );
}

root.render(<BioForm />);
setTimeout(() => {
  const textarea = mountNode.querySelector('textarea');
  const valueSetter = Object.getOwnPropertyDescriptor(
    HTMLTextAreaElement.prototype,
    'value',
  ).set;
  valueSetter.call(textarea, 'Writes React notes.');
  textarea.dispatchEvent(new Event('input', { bubbles: true }));
  setTimeout(() => {
    console.log('textarea value:', textarea.value);
  }, 0);
}, 20);

Waiting to run

Not run yet.

A select also receives its selected value from value and updates through onChange.[3]

const { useState } = React;

function RoleForm() {
  const [role, setRole] = useState('designer');

  return (
    <div>
      <label>
        Role
        <select
          value={role}
          onChange={(event) => setRole(event.currentTarget.value)}
        >
          <option value="designer">Designer</option>
          <option value="engineer">Engineer</option>
          <option value="manager">Manager</option>
        </select>
      </label>
      <p>Selected: {role}</p>
    </div>
  );
}

root.render(<RoleForm />);
setTimeout(() => {
  const select = mountNode.querySelector('select');
  const valueSetter = Object.getOwnPropertyDescriptor(
    HTMLSelectElement.prototype,
    'value',
  ).set;
  valueSetter.call(select, 'engineer');
  select.dispatchEvent(new Event('change', { bubbles: true }));
  setTimeout(() => {
    console.log(mountNode.textContent);
  }, 0);
}, 20);

Waiting to run

Not run yet.

The useful generalization is:

the form control renders from state, and its change event stores the next state

The exact prop and event field depend on the form control.

One object #

Real forms usually have more than one field. The state can stay together as one object when the fields describe one form.

const { useState } = React;

function ProfileForm() {
  const [profile, setProfile] = useState({
    name: '',
    role: 'designer',
    available: false,
  });

  function updateField(field, value) {
    setProfile((current) => ({
      ...current,
      [field]: value,
    }));
  }

  return (
    <form>
      <label>
        Name
        <input
          value={profile.name}
          onChange={(event) => updateField('name', event.currentTarget.value)}
        />
      </label>

      <label>
        Role
        <select
          value={profile.role}
          onChange={(event) => updateField('role', event.currentTarget.value)}
        >
          <option value="designer">Designer</option>
          <option value="engineer">Engineer</option>
          <option value="manager">Manager</option>
        </select>
      </label>

      <label>
        <input
          type="checkbox"
          checked={profile.available}
          onChange={(event) => updateField('available', event.currentTarget.checked)}
        />
        Available for projects
      </label>

      <p>
        {profile.name || 'Someone'} is a {profile.role}
        {profile.available ? ' and is available.' : '.'}
      </p>
    </form>
  );
}

root.render(<ProfileForm />);
setTimeout(() => {
  const name = mountNode.querySelector('input:not([type="checkbox"])');
  const select = mountNode.querySelector('select');
  const checkbox = mountNode.querySelector('input[type="checkbox"]');
  const inputValueSetter = Object.getOwnPropertyDescriptor(
    HTMLInputElement.prototype,
    'value',
  ).set;
  const selectValueSetter = Object.getOwnPropertyDescriptor(
    HTMLSelectElement.prototype,
    'value',
  ).set;

  inputValueSetter.call(name, 'Maya');
  name.dispatchEvent(new Event('input', { bubbles: true }));
  selectValueSetter.call(select, 'engineer');
  select.dispatchEvent(new Event('change', { bubbles: true }));
  checkbox.click();

  setTimeout(() => {
    console.log(mountNode.textContent);
  }, 0);
}, 20);

Waiting to run

Not run yet.

The object state is still replaced, not mutated. The update uses the previous object, copies its existing fields, and replaces only the field that changed:

const current = {
  name: 'Maya',
  role: 'designer',
  available: false,
};
const field = 'role';
const value = 'engineer';

const next = {
  ...current,
  [field]: value,
};

console.log(next);

Waiting to run

Not run yet.

Each event changes one field, while the next render still needs all the other fields.

Submitting the form #

Controlled fields keep current form data in React state while the user edits. Submission is a separate browser event. By default, a browser form submission navigates or reloads the page. A client-side React form usually prevents that default behavior and handles the current state in the submit handler.[4]

const { useState } = React;

function SignupForm() {
  const [email, setEmail] = useState('');

  function handleSubmit(event) {
    event.preventDefault();
    console.log('submit email:', email);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Email
        <input
          type="email"
          value={email}
          onChange={(event) => setEmail(event.currentTarget.value)}
        />
      </label>
      <button type="submit">Join</button>
    </form>
  );
}

root.render(<SignupForm />);
setTimeout(() => {
  const input = mountNode.querySelector('input');
  const form = mountNode.querySelector('form');
  const valueSetter = Object.getOwnPropertyDescriptor(
    HTMLInputElement.prototype,
    'value',
  ).set;
  valueSetter.call(input, 'maya@example.com');
  input.dispatchEvent(new Event('input', { bubbles: true }));
  setTimeout(() => {
    form.requestSubmit();
  }, 0);
}, 20);

Waiting to run

Not run yet.

The submit handler does not need to query the DOM to know the email. The current email is already state for this render. That is the payoff of controlled inputs: render logic, derived text, disabled buttons, validation messages, and submit handlers can all use the same state values.

Deriving UI #

Because the form data is state, the rest of the UI can be calculated from it during render.

const { useState } = React;

function SignupForm() {
  const [email, setEmail] = useState('');
  const isValid = email.includes('@');

  return (
    <form>
      <label>
        Email
        <input
          type="email"
          value={email}
          onChange={(event) => setEmail(event.currentTarget.value)}
        />
      </label>
      <p>{isValid ? 'Ready to submit' : 'Enter an email address'}</p>
      <button type="submit" disabled={!isValid}>
        Join
      </button>
    </form>
  );
}

root.render(<SignupForm />);
setTimeout(() => {
  const input = mountNode.querySelector('input');
  const valueSetter = Object.getOwnPropertyDescriptor(
    HTMLInputElement.prototype,
    'value',
  ).set;
  valueSetter.call(input, 'maya@example.com');
  input.dispatchEvent(new Event('input', { bubbles: true }));
  setTimeout(() => {
    const button = mountNode.querySelector('button');
    console.log('message:', mountNode.querySelector('p').textContent);
    console.log('button disabled:', button.disabled);
  }, 0);
}, 20);

Waiting to run

Not run yet.

isValid is derived from email during the current render. That keeps the form from storing the same fact twice. The source value is email; the validity message and button state are consequences of that value.

Filling the hole #

Forms expose a boundary between DOM-owned state and React-owned state.

An uncontrolled input can keep its own current value in the DOM. That is useful when React only needs an initial value or wants to read the form at submit time. A controlled input closes a loop through React state:

  1. render passes the current state into the form control
  2. the user edits the control
  3. onChange reports the next value
  4. the handler stores that value in state
  5. React renders again with the new value

That makes the form data available during render, not only after reading from the DOM.

Final definition #

A controlled input is a form control whose displayed state is driven by React state. For text-like controls, React passes value and updates that value from onChange. For checkboxes and radio buttons, React passes checked and updates it from onChange.

The form control still receives browser events, but React state is the source used to render the current form UI.

Summary #

Forms fill the hole after state:

  • uncontrolled inputs can keep their own current values in the DOM
  • onChange lets React hear about user edits
  • controlled text inputs use value plus an onChange state update
  • controlled checkboxes use checked plus an onChange state update
  • defaultValue and defaultChecked are initial values for uncontrolled inputs
  • submit handlers can prevent the browser default and use current React state
  • derived form UI should usually be calculated during render from the source state

Notes

  1. The live demos in this article use the React 19.2.5 and react-dom 19.2.5 development modules. They focus on ordinary client-side form state rather than React form actions, server actions, validation libraries, or router integrations.

References

  1. React DOM: `<input>` (opens in a new tab) · Back
  2. React DOM: `<textarea>` (opens in a new tab) · Back
  3. React DOM: `<select>` (opens in a new tab) · Back
  4. React DOM: `<form>` (opens in a new tab) · Back