Skip to content

Forms

Type into a plain HTML input and the browser handles the value on its own: it stores what you typed and redraws the cursor without any code from you. Wire that input's value to a piece of state and update that state on onChange, and the input starts working like everything else in React: state holds the truth about what's on the page, and the input reflects it. That's a controlled input, one where React decides what's shown at every moment.

Here's a small form with one text input:

jsx
function NameForm() {
  const [text, setText] = useState('')

  function handleSubmit(event) {
    event.preventDefault()
    console.log(text)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button>Submit</button>
    </form>
  )
}

The input's value comes from the text state, and every keystroke fires onChange, which calls setText with the new value. The state and the input stay in sync because React re-renders the input with whatever text currently holds. Submitting a form reloads the page by default, so handleSubmit calls event.preventDefault() first, then does whatever the app needs, logging the text here, sending it to a server elsewhere.

Controlled and uncontrolled inputs

The input above is a controlled input, because its value lives in React state and React decides what it displays. Give it a value without an onChange, and the input turns read-only: React keeps painting the same value back on every render, and nothing you type ever reaches state, so pair the two whenever a field is controlled. An input can also be uncontrolled: the DOM keeps track of its own value, and you read it out only when you need it, through a ref for a single field (a way to reach a DOM element directly, covered in full in Refs and the DOM) or through the form's FormData for a whole form at submit time. Controlled inputs give the component the current value on every keystroke, which is what live validation, character counts, or fields that other parts of the UI depend on need. Uncontrolled inputs skip a render per keystroke and reach for the value only at submit time, which is less code for a simple field that nothing else needs to watch.

Checkboxes

Checkboxes follow the same pattern, but their state lives in checked instead of value:

jsx
function Newsletter() {
  const [subscribed, setSubscribed] = useState(false)

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

e.target.checked is a boolean, and subscribed drives whether the box is ticked: one prop, one change handler, same shape as the text input.

Select

A <select> also takes a value, set on the select itself rather than on the selected option:

jsx
function ColorPicker() {
  const [color, setColor] = useState('red')

  return (
    <select value={color} onChange={e => setColor(e.target.value)}>
      <option value="red">Red</option>
      <option value="green">Green</option>
      <option value="blue">Blue</option>
    </select>
  )
}

Set value on the select, and React picks the matching option for you. There's no need to add a selected attribute to any of the option elements.

Textarea

In HTML, a textarea carries its text between the opening and closing tags. React treats it as an input like any other, so the text arrives through a value prop and onChange keeps it in sync:

jsx
function Feedback() {
  const [message, setMessage] = useState('')

  return (
    <textarea
      value={message}
      onChange={e => setMessage(e.target.value)}
      rows={4}
    />
  )
}

rows sets how tall the box starts. defaultValue is the uncontrolled equivalent of value here: it sets the starting text once and leaves the DOM to track it from there.

Radio buttons

Several inputs share one name, which tells the browser they belong together. Only one can be picked. In React the group shares one piece of state, and each input's checked compares against it:

jsx
function ShippingSpeed() {
  const [speed, setSpeed] = useState('standard')

  return (
    <fieldset>
      <legend>Shipping speed</legend>
      {['standard', 'express', 'overnight'].map(option => (
        <label key={option}>
          <input
            type="radio"
            name="speed"
            value={option}
            checked={speed === option}
            onChange={e => setSpeed(e.target.value)}
          />
          {option}
        </label>
      ))}
    </fieldset>
  )
}

fieldset and legend label the group for screen readers, and each label takes a key because the options come from an array (lists and keys).

checked={speed === option} is true for exactly one option, so one state value covers the whole group, whereas a checkbox carries its own boolean.

Submitting with an action

A <form> can also take an action function. React calls it when the form is submitted and hands it a FormData object holding the form's values:

jsx
function Signup() {
  function handleSignup(formData) {
    const values = Object.fromEntries(formData)
    console.log(values.email, values.password)
  }

  return (
    <form action={handleSignup}>
      <input name="email" type="email" />
      <input name="password" type="password" />
      <button>Sign up</button>
    </form>
  )
}

There's no event here, so there's nothing to call preventDefault() on. React stops the browser's default page reload for you.

Object.fromEntries(formData) turns that FormData into a plain object in one step. The keys come from each input's name attribute, which is why every input in the form needs one. Where several inputs share a name and can be sent together, like a checkbox group, only the last value survives, and formData.getAll reads them all.

The inputs above are uncontrolled: the DOM holds each value until submit. That matters for the one behavior that surprises people arriving from onSubmit: React resets the form once the action resolves, clearing uncontrolled fields like these. A controlled field re-renders from state and keeps its value.

Each path suits a different kind of form. An action fits a form whose job is to gather values and hand them off, usually to a server, where the wait is real and the result has to come back into the UI. The values arrive collected and named. onSubmit with controlled inputs fits a form that has to react while the user types, for live validation, a character count, or a field that changes what the rest of the form shows.

Version note

Form actions arrived in React 19. On React 18 and earlier, <form action={handleSignup}> does not throw. React drops the attribute, because a function is not a valid attribute value, and warns about it in development. The form then submits to the current URL, so the page reloads and the function never runs. Submission on those versions goes through onSubmit with event.preventDefault().

Real forms rarely stop at one field. A signup form asking for a name, an email, and a password could track three separate useState calls, but one state object and a single change handler cover any number of fields without repeating yourself:

jsx
function SignupForm() {
  const [values, setValues] = useState({ name: '', email: '', password: '' })

  function handleChange(event) {
    const { name, value } = event.target
    setValues(prev => ({ ...prev, [name]: value }))
  }

  return (
    <form>
      <input name="name" value={values.name} onChange={handleChange} />
      <input name="email" value={values.email} onChange={handleChange} />
      <input type="password" name="password" value={values.password} onChange={handleChange} />
    </form>
  )
}

Each input's name attribute matches a key in values, so handleChange reads event.target.name to know which key to update and [name]: value writes it back under that same key. Add another field to the form, give its input a matching name, and the existing handler already covers it.

Validation can run at two different points, and they serve different purposes. Validating on change, checking the new value inside handleChange as it comes in, gives feedback right away, useful for something like a password strength meter. It also means an error can show up for a field the user hasn't finished typing yet. Validating on submit, checking the whole values object inside the submit handler before anything happens with it, waits until the user is done, which tends to be the better default for required-field and format checks. Plenty of forms mix the two: a light check on change, a full pass on submit.

A submit that talks to a server takes time, and a button that stays clickable during that wait invites a second click and a duplicate submission. Track a submitting flag in state, set it before the request starts, and disable the button while it's true:

jsx
function SignupForm() {
  const [values, setValues] = useState({ name: '', email: '', password: '' })
  const [submitting, setSubmitting] = useState(false)

  async function handleSubmit(event) {
    event.preventDefault()
    setSubmitting(true)
    await saveSignup(values)
    setSubmitting(false)
  }

  return (
    <form onSubmit={handleSubmit}>
      {/* inputs go here */}
      <button disabled={submitting}>{submitting ? 'Submitting...' : 'Sign up'}</button>
    </form>
  )
}

disabled={submitting} grays the button out and blocks clicks for as long as the request is in flight, then hands control back the moment it resolves. A form that only gathers values and sends them can hand the whole job to an action instead, where useActionState wraps the action and gives you that pending flag without wiring one up.

Every keystroke in a controlled input makes a round trip: the DOM fires an event, your handler calls setText, React re-renders the component, and the new value prop lands back on the same input. It feels instant, but the input's displayed value is being set by React on each render. The DOM element has no memory of its own here, it displays whatever state says right now.

That round trip is also the cost. A form with many controlled fields re-renders the whole component on every keystroke in any of them. Usually that's cheap enough to ignore. When it isn't, or when a field's value doesn't need to affect anything else until submit, an uncontrolled input with a ref is the simpler choice:

jsx
function NameForm() {
  const inputRef = useRef(null)

  function handleSubmit(event) {
    event.preventDefault()
    console.log(inputRef.current.value)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input ref={inputRef} defaultValue="" />
      <button>Submit</button>
    </form>
  )
}

No state, no re-render per keystroke. defaultValue does the same job here that it does on a textarea. Reach for this when a field is fully isolated: nothing renders differently while the user is typing in it.

useActionState wraps an action and hands back a pending flag alongside whatever the action returned, so a submitting flag and an error message stop being separate pieces of state you wire up yourself. Server Actions and the framework integrations built on top of them take this further, running the action itself on the server, past where this handbook goes.

JunoState drives the input The pattern to hold onto is small: the input's value comes from state, and onChange updates that state. Once that clicks for a text input, it works the same way for selects and textareas, and for checkboxes and radio buttons with checked carrying the state. Submitting is its own step: a form's submit runs a function you write, and that function decides what happens with the values.
JunoState drives the input Controlled inputs are the default: value (or checked for checkboxes) tied to state, onChange updating it, onSubmit with event.preventDefault() handling the submit. For a multi-field form, one state object and a handleChange keyed by name replace a pile of separate useState calls, and a submitting flag keeps the button honest while a request is in flight. An action function on the form is the other route: React hands it a FormData object and takes care of the event, and useActionState takes over that submitting flag once a server is involved.
JunoState drives the input Controlled inputs are React owning the DOM's value on every render, which is what gives you a value to read, validate, or derive from at any point. Know when that round trip isn't earning its keep and an uncontrolled ref will do, and reach for a form action when a form's job is to collect values and hand them off, with useActionState carrying the pending state once a server is involved.

Next up: Lifting state up, where two components need to share the same piece of state.