React in Depth

Forms, Events and Controlled Inputs

Controlled and uncontrolled inputs, form actions and useActionState in React 19, validation, and accessibility basics.

Lesson 0414 min read#react#forms#events#accessibility

On this page

  1. Words used in this lesson
  2. Events in React
  3. Controlled inputs
  4. Uncontrolled inputs
  5. Form actions in React 19
  6. Validation
  7. Accessibility basics
  8. Try it
  9. Interview questions
  10. Key takeaways
  11. What's next

In one line: a form input is either controlled (React state is the truth and the input just shows it) or uncontrolled (the browser keeps the value and you read it when you need it). React 19 adds a third, simpler way for submitting: pass a function to <form action={...}>, and React gives you the form data, a pending flag, and the result with useActionState.

Forms are where beginners meet state, events and bugs all at once. A text box that does not accept typing, a warning about "controlled and uncontrolled", a page that reloads when you press Enter: these all come from a few simple ideas. This lesson explains each idea with small examples that I ran, including a run in a real headless Chromium for the React 19 form features and the accessibility checks.

The lesson before this one was Hooks in Depth. Next comes Data Fetching, Caching and Server State, where a form's result usually goes to a server.

What I ran / checked. Node 22.22.0, React 19.3.0, react-dom 19.3.0, jsdom 29.1.1, @testing-library/react 16.3.3 (I installed it and checked that getByLabelText, getByRole and fireEvent.click work on a small form), esbuild 0.28.2, and Playwright driving headless Chromium 141 against an esbuild bundle in development mode with Strict Mode on. Controlled-input and event outputs come from jsdom. The action, pending, error, reset and optimistic outputs come from the form logic running in both jsdom and the real browser. Jsdom needed one fix: React used Node's own FormData unless I pointed it to jsdom's FormData, and it crashed otherwise. I read the react.dev pages for <input>, <form>, useActionState, useFormStatus, useOptimistic, useId, and the Strict Mode reference. I did not run Server Functions ("use server") because that needs a framework such as Next.js. Where I mention them, it is from the docs. Warning text can change with versions.

Words used in this lesson

  • Controlled input is an input whose value comes from React state. You pass value and onChange.
  • Uncontrolled input is an input that keeps its own value in the DOM. You may give it defaultValue and read it later.
  • Event handler is a function you pass to a prop such as onClick. React calls it when that event happens.
  • Synthetic event is React's wrapper around the browser event. It works the same in all browsers. In React 17 and later it is very close to the native event.
  • Event bubbling means an event goes from the clicked element up through its parents.
  • FormData is a browser object that reads all the named fields of a form.
  • Action is a function you pass to <form action>. React calls it with the form data on submit, inside a transition.
  • Transition is a React way to mark an update as not urgent, so the screen stays responsive. An action runs inside one.
  • Pending means an action has started and has not finished yet.
  • Optimistic update means showing the expected result right away, before the server confirms it.
  • Accessible name is the text a screen reader reads for a control. A visible <label> gives an input its name.
  • ARIA is a set of HTML attributes (like aria-invalid) that add meaning for assistive tools.

Events in React

An event handler is a function you pass as a prop:

JavaScript
<button onClick={handleClick}>Save</button>

Names are camelCase: onClick, onChange, onSubmit, onKeyDown, onFocus. Pass the function itself. Do not call it. In lesson 0 I showed that onClick={f()} runs f during render.

A handler gets an event object with target (the element that fired it), currentTarget (the element the handler is attached to), preventDefault() and stopPropagation().

Events bubble

When you click a button inside a div, the button's handler runs first, then the div's, then up the tree. That is bubbling. I tested the order and stopPropagation:

JavaScript
<div onClick={() => L("div")}>  <button onClick={() => L("button")}>a</button>  <button onClick={(e) => { e.stopPropagation(); L("button (stopped)"); }}>b</button></div>
TEXT
click a: button -> divclick b: button (stopped)

Clicking a ran both, child first. Clicking b stopped at the button. Use stopPropagation rarely, since it hides the event from other code that may rely on it (analytics, "click outside to close"). React attaches its listeners at the root container and sorts events out from there, which is why you never call removeEventListener for JSX handlers.

preventDefault and submit

A <form> with a submit button, by default, makes the browser send a request and reload the page. In a single-page app you stop that:

JavaScript
function Login() {  const [msg, setMsg] = useState("");  function onSubmit(e) {    e.preventDefault();                       // do not reload    const data = new FormData(e.currentTarget);    setMsg("hello " + data.get("user"));  }  return <form onSubmit={onSubmit}>    <input name="user" defaultValue="" /><button>go</button><p>{msg}</p>  </form>;}

I typed "ada" and submitted:

TEXT
onSubmit with preventDefault -> hello ada

This is the classic way and it still works in React 19. Notice new FormData(e.currentTarget): it reads every field that has a name, without any state. Hold that thought.

<button> inside a form is a submit button by default. A "Cancel" or "Add row" button inside a form will submit it unless you write type="button". This is one of the most common surprise bugs.

Controlled inputs

A controlled input gets its value from state and tells React about every change:

JavaScript
function Name() {  const [name, setName] = useState("");  return <input value={name} onChange={e => setName(e.target.value)} />;}

The loop is: you type, onChange fires, you set state, React re-renders, and the input shows the new state. The input never changes on its own. React holds the steering wheel. That has a nice side effect: you can change what the user typed. I made an input that upper-cases everything:

JavaScript
function Upper() {  const [t, setT] = useState("");  return <input value={t} onChange={e => setT(e.target.value.toUpperCase())} />;}
TEXT
typed 'hello', input shows: HELLO

React's onChange fires on every keystroke (it behaves like the browser's input event, not the native change event, which waits for blur). I typed three times:

TEXT
three keystrokes -> 3 onChange calls

Each keystroke means a re-render of the component that owns the state. For one small form that is nothing. If the form is inside a huge component, move the state down into a smaller component, or read the values only on submit (uncontrolled, below).

The two famous warnings

1. value without onChange. If you give value but no way to change it, the field is stuck. React tells you:

TEXT
You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field.If the field should be mutable use `defaultValue`. Otherwise, ...

Either add onChange, or use defaultValue (for an uncontrolled field), or add readOnly if you really want it fixed.

2. Uncontrolled to controlled. I made state with no initial value: useState() is undefined, passed it as value, and then typed:

TEXT
A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. ...

value={undefined} means "no value prop", so React treated the input as uncontrolled. When state became "x", it became controlled. Always start with a real value: useState(""). (For a checkbox use checked with a boolean, and for a number input keep a string in state, since the user can type "" or "-" on the way.) For a null value React also warns, so use "".

Many fields

Do not make ten useState calls for ten fields. Keep one object, and use the input's name to know which field changed:

JavaScript
function Signup() {  const [form, setForm] = useState({ email: "", plan: "free", agree: false });  const set = (e) => {    const { name, type, value, checked } = e.target;    setForm(f => ({ ...f, [name]: type === "checkbox" ? checked : value }));  };  return <form>    <input name="email" value={form.email} onChange={set} />    <select name="plan" value={form.plan} onChange={set}>      <option value="free">free</option><option value="pro">pro</option>    </select>    <input name="agree" type="checkbox" checked={form.agree} onChange={set} />    <output>{JSON.stringify(form)}</output>  </form>;}

I typed an email, chose "pro", and ticked the box:

TEXT
{"email":"a@b.co","plan":"pro","agree":true}

Two details. The updater form setForm(f => ...) (lesson 1) avoids stale values. And [name]: is a computed key, so one handler serves all fields. Note that in React, <select> and <textarea> take value like an input. (You do not put selected on an <option> or children text in a <textarea>.)

When controlled is the right choice

  • You must validate or format as the user types (phone numbers, uppercase, character count).
  • You must enable or disable a button depending on the values.
  • Several inputs depend on each other.
  • You need to reset or set the value from code.

Uncontrolled inputs

An uncontrolled input is a normal HTML input. The browser keeps the text. React only sets the starting value with defaultValue (or defaultChecked) and you read the result when needed, by FormData or a ref:

JavaScript
<form onSubmit={e => { e.preventDefault(); const fd = new FormData(e.currentTarget); save(Object.fromEntries(fd)); }}>  <input name="email" defaultValue="" />  <button>Save</button></form>

This makes less code and no re-render per keystroke. Object.fromEntries(fd) turns the form data into a plain object (careful: it keeps only the last value of repeated names, so use fd.getAll(name) for checkbox groups). The cost is that you can not react to every change without extra handlers. An uncontrolled file input (type="file") is always uncontrolled, because browsers do not allow code to set its value.

A rule of thumb that I use: start with uncontrolled and FormData. Switch to controlled for a field the moment you need its live value.

Form actions in React 19

React 19 lets you pass a function to a form's action prop. On submit, React stops the default reload, collects the form into a FormData, calls your function with it, and runs it inside a transition (so the page stays responsive and React knows the action is pending). You no longer write preventDefault and the FormData line yourself.

JavaScript
async function save(formData) {  const email = formData.get("email");  await fetch("/api/subscribe", { method: "POST", body: formData });}<form action={save}>  <input name="email" />  <button>Subscribe</button></form>

A new thing happens by default: after the action finishes, React resets the form's uncontrolled fields. This surprised me, so I checked it in the real browser. I will show it in the example below.

useActionState: the result and the pending flag

Most forms need three things: run something, know if it is running, and show what came back (an error or a success message). useActionState gives all three:

JavaScript
const [state, formAction, isPending] = useActionState(reducerAction, initialState);
  • reducerAction(previousState, formData) is your function. What it returns becomes the new state.
  • formAction is what you pass to <form action>.
  • isPending is true while it runs.
  • initialState is the first state.

Here is the form I ran. The action waits 30 ms (a fake server), then validates:

JavaScript
async function subscribe(prev, formData) {  const email = formData.get("email");  await new Promise(r => setTimeout(r, 30));  if (!email.includes("@")) return { ok: false, error: "That does not look like an email.", email };  return { ok: true, error: null, email };}function Form() {  const [state, formAction, isPending] = useActionState(subscribe, { ok: null, error: null, email: "" });  return (    <form action={formAction}>      <input name="email" defaultValue={state.email} />      <SubmitButton />      {state.error && <p role="alert">{state.error}</p>}      {state.ok && <p>Thanks!</p>}    </form>  );}

I typed ada and submitted. The log shows the renders:

TEXT
mount  Form render isPending=false state={"ok":null,"error":null,"email":""}  SubmitButton render pending=falsesubmit 'ada' (before the action finishes)  action runs: prev={"ok":null,"error":null,"email":""} email=ada  Form render isPending=true state={"ok":null,"error":null,"email":""}  SubmitButton render pending=trueafter action finishes  Form render isPending=false state={"ok":false,"error":"That does not look like an email.","email":"ada"}  SubmitButton render pending=false

Follow it: on submit React called my function with the previous state and the form data. The form re-rendered with isPending=true. When the promise resolved, the state became the object I returned, and isPending went back to false. The error showed up on the page. Notice that I returned the typed email inside the state and used defaultValue={state.email}. That is on purpose, and the next test shows why.

useFormStatus: pending state for children

A button deep in the tree often needs to know if its form is submitting, without passing a prop down. useFormStatus() (from react-dom) returns { pending } for the nearest parent <form>:

JavaScript
import { useFormStatus } from "react-dom";function SubmitButton() {  const { pending } = useFormStatus();  return <button type="submit" disabled={pending}>{pending ? "Saving..." : "Subscribe"}</button>;}

It must be called from a component inside the form, not in the component that renders the <form> itself. (That is why SubmitButton is its own component.) Disabling the button while pending prevents double submits.

What I saw in a real browser

I bundled a version of this form and drove it with Playwright in headless Chromium, in development mode with Strict Mode on. The form had an email field with a label, an action that waits 300 ms and rejects emails without @, and aria attributes for errors (explained below). The steps and my printed results:

TEXT
label finds input: 1click label focuses input: trueduring action, button: Saving... disabled: truealert: Enter a valid email.input value after failed submit (no defaultValue): ""aria-invalid: true | describedby -> Enter a valid email.saved: saved: ada@x.io | alert count: 0 | input: ""

Line by line:

  • Playwright could find the input by its label text, and clicking the label moved the focus to the input. Both are what a good <label> gives you for free.
  • While the action ran, the button showed "Saving..." and was disabled.
  • The error appeared as an element with role="alert", and the input pointed to it with aria-describedby.
  • The input was empty after the failed submit. Here the field had no defaultValue from state, and React reset it after the action. This is the new default in React 19: the form resets after an action. So the user who typed ada lost their text on an error. That is why, in the jsdom example above, I returned email in the state and used defaultValue={state.email}: it restores the value after React's reset.
  • A valid submit worked, the error went away, and the field was reset (a normal behaviour for a successful submit).

If your form shows errors after submit, make sure the user does not lose what they typed. Either return the submitted values in the state and use them as defaultValue (as above), or make those inputs controlled with useState.

useOptimistic: show the result before the server answers

For things like adding a to-do, waiting for the server makes the UI feel slow. useOptimistic lets you show the expected result immediately. React shows the optimistic value while the action runs, then switches to the real state when it finishes (or goes back if it failed):

JavaScript
function Todos() {  const [todos, setTodos] = useState(["one"]);  const [optimistic, addOptimistic] = useOptimistic(todos, (cur, t) => [...cur, t + " (sending...)"]);  async function add(fd) {    const t = fd.get("todo");    addOptimistic(t);                                  // shows right away    await new Promise(r => setTimeout(r, 400));        // fake server    setTodos(cur => [...cur, t]);                      // real update  }  return <form action={add}>    <input name="todo" aria-label="todo" /><button>Add</button>    <ul>{optimistic.map((t, i) => <li key={i}>{t}</li>)}</ul>  </form>;}

In Chromium I typed "two", clicked Add, and read the list at 150 ms and 650 ms:

TEXT
list at 150ms: [ 'one', 'two (sending...)' ]list at 650ms: [ 'one', 'two' ]

At 150 ms the item was already there, marked as sending. At 650 ms the real state took over, and the marker was gone. (I used the index as key here to keep the demo short, which is fine because the list only grows at the end and has no per-item state. Lesson 1 told you when it is not fine.) I did not run a failing action. The docs say the optimistic value is shown only while the action is pending, so after it ends React goes back to the real state.

Server Functions

In a framework with Server Components (Next.js), the action can be a Server Function: a function marked "use server" that runs on the server, and the form can even work before JavaScript loads. I did not run this, since it needs a framework. In the useActionState docs, the optional third argument, permalink, exists for that case. Lesson 8 explains Server and Client Components in plain words.

Validation

There are three layers, and you need all of them.

  1. Browser (HTML) validation is free: required, type="email", minLength, pattern. The browser blocks submit and shows its own message. Use it as a first line of help.
  2. Your own validation in JavaScript, for rules HTML can not express ("username is taken", "end date after start date").
  3. Server validation is the one that counts. Anyone can bypass the browser by sending a request by hand. Always validate again on the server. Client-side validation is for the user's comfort, not for security. This links to the security series: treat every input as hostile.

A pattern that works well with useActionState: the action returns an object of field errors, { errors: { email: "..." }, values: {...} }, and each field shows its own error. When to validate: on submit is the least annoying. On blur (when leaving the field) is a good compromise. On every keystroke is only good for things like password strength meters, because it shows an error before the user finished typing.

Accessibility basics

Good forms work with a keyboard and a screen reader. Most of it is plain HTML that React lets you keep:

  1. Every input needs a visible <label>, connected to it. Use htmlFor (React's name for for) and id, or wrap the input inside the label. A connected label gives the input its accessible name, and makes the label clickable (I verified both in Chromium above).
  2. Use useId to make ids when a component is reused many times. Hard-coded ids would repeat.
  3. Mark invalid fields: aria-invalid="true", and link the message with aria-describedby.
  4. Announce errors: an element with role="alert" is read by screen readers when it appears.
  5. Use real buttons (<button>), not a div with onClick. Buttons work with Enter and Space, and get focus for free.
  6. Do not rely on placeholder as a label. It disappears when typing.
  7. Move focus on error if the form is long (focus the first invalid field).

My field component, which I ran in the browser:

JavaScript
function Field({ label, name, error }) {  const id = useId();  return <div>    <label htmlFor={id}>{label}</label>    <input id={id} name={name} aria-invalid={!!error} aria-describedby={error ? id + "-err" : undefined} />    {error && <p id={id + "-err"} role="alert">{error}</p>}  </div>;}

The check in the browser: getByLabel("Email") found the input, the error text was reachable through aria-describedby, and aria-invalid was true. A tool like Testing Library encourages this style: it finds elements by label and role, as users and screen readers do. I also confirmed with @testing-library/react that getByLabelText("Name") returned the input and fireEvent.click(getByRole("button")) changed the button text. Lesson 8 covers testing in depth.

Try it

  1. Write a controlled input that only accepts digits (strip other characters in onChange). What do you see when you type letters?
  2. Give an input value="hi" with no handler. Read the console warning. Fix it two ways.
  3. Create const [name, setName] = useState() (no argument), use it as value, and type. What does the console say? Fix it.
  4. Build a three-field signup using a single state object and one onChange.
  5. Rewrite your form with no state at all: onSubmit with FormData. What do you lose?
  6. Add a "Cancel" <button> inside a form without type. What happens when you click it? Fix it.
  7. Build a form with action and useActionState. Make the action wait one second and return an error. Does the typed text stay? Fix it with defaultValue.
  8. Add useFormStatus in a SubmitButton component. Why does it not work if you call it in the same component that renders the <form>?
  9. Add useOptimistic to a list. Make the action throw. What happens to the optimistic item?

Answers

  1. Letters do nothing: setV(e.target.value.replace(/\D/g, "")) returns the string without them, so the input shows the same value as before.
  2. The warning says the field is read-only. Fixes: add onChange and state, or use defaultValue (or readOnly).
  3. "Changing an uncontrolled input to be controlled." Use useState("").
  4. One object state, inputs with name, and a handler that sets [name]: value.
  5. You lose the live value (no instant validation or character count). You gain simplicity and no re-render per keystroke.
  6. It submits the form, because a button defaults to type submit. Use type="button".
  7. The field is reset by React after the action. Return the values in state and use them as defaultValue.
  8. useFormStatus reads the status of a parent form. Called in the component that renders the form, there is no parent form above it, so pending stays false.
  9. I did not run the failing case, so check it in your own project. The docs say the optimistic value is only shown while the action is pending, so after the action ends React shows the real state, which has no new item. Show an error message so the user knows.

Interview questions

What is the difference between a controlled and an uncontrolled input? (Must know)

A controlled input gets its value from React state via value and updates it in onChange. React is the single source of truth, so I can validate, format, or set it from code. An uncontrolled input keeps its value in the DOM. I give it defaultValue and read it later through FormData or a ref. Controlled gives me live control at the cost of a re-render per keystroke. Uncontrolled is less code. I start with uncontrolled and switch to controlled when I need the live value.

What does the "changing an uncontrolled input to be controlled" warning mean? (Must know)

It means the input's value went from undefined or null (no value, so uncontrolled) to a real value. It happens when state starts as undefined and I pass it as value. In my test, useState() with no argument triggered it as soon as I typed. The fix is to start with an empty string, useState(""). Switching modes during the life of the input is not supported, because the DOM and React would disagree about who owns the value.

Why does onChange in React fire on every keystroke? (Senior signal)

React's onChange behaves like the browser's input event, so it fires for each change of the value, not only when the field loses focus like the native change event. I measured three keystrokes giving three calls. It is useful for live validation, but it means a re-render on each keystroke for controlled inputs, so I keep the state close to the input or use uncontrolled inputs when the form is large.

How do I stop a form from reloading the page? (Must know)

With onSubmit, I call e.preventDefault(). With React 19's <form action={fn}>, React already prevents the default and calls my function with a FormData, so I don't need it. In both cases I should use a real <button type="submit"> so Enter works. I also remember that a button inside a form submits by default, so other buttons need type="button".

What does <form action={fn}> do in React 19? (Must know)

React intercepts the submit, builds a FormData from the named fields, and calls my function with it inside a transition. That gives me a pending state for free, and the page stays responsive. When the action completes, React resets the form's uncontrolled fields. It also works with Server Functions in frameworks, even before JavaScript loads. It removes the preventDefault and new FormData(e.target) boilerplate.

How does useActionState work? (Senior signal)

const [state, formAction, isPending] = useActionState(fn, initialState). I pass formAction to the form. On submit, React calls fn(previousState, formData). Whatever fn returns becomes the new state, and isPending is true while it runs. Multiple dispatches are queued and run in order. In my run the log showed the action receiving the previous state, isPending true during the wait, and then the new state with the error. I use it to hold validation errors and success messages.

After a failed submit, my form lost what the user typed. Why? (Trap)

Because React 19 resets uncontrolled form fields after the action completes. I saw it in Chromium: an invalid email left the input empty. To fix it, I return the submitted values from the action in the state and use them as defaultValue, or I make the field controlled. Not doing so is a UX bug that testers love to find.

When does useFormStatus work and when doesn't it? (Senior signal)

It reads the status of the parent <form>, so it must be called in a component rendered inside the form, not in the component that renders the form. A common pattern is a small SubmitButton component. In my test it showed "Saving..." and disabled the button during the action. Called in the same component as the form, it always returns pending: false.

What is useOptimistic and what happens if the request fails? (Senior signal)

It returns a value that is the real state plus a temporary change that I apply while an action is in flight. The user sees the result at once. When the action ends, React drops the temporary value and shows the real state. So if the request failed and I did not update the real state, the optimistic item should disappear (I only ran the success case, so I would test this in my own app). In my browser run the item showed as "two (sending...)" at 150 ms and as "two" at 650 ms. I add an error message so the disappearance is not silent.

How do you make a form accessible? (Must know)

Each field has a visible <label> connected with htmlFor and id (I use useId in reusable components). Errors are linked with aria-describedby, the field has aria-invalid, and the message has role="alert" so a screen reader announces it. I use real <button> elements, make sure the form works with only a keyboard, never use the placeholder as the label, and test using queries by label and role, as Testing Library does. In Chromium I confirmed that clicking the label focused the input and that Playwright found the field by its label.

Is client-side validation enough? (Trap)

No. It is for convenience. Anyone can send a request without my page, so I validate again on the server, and treat all input as untrusted. I use HTML attributes (required, type="email") and my own checks for a good user experience, and the server's checks for correctness and security.

When do you choose useReducer or a form library for a form? (Senior signal)

For a small form, a single object in useState, or uncontrolled fields with FormData and useActionState, is enough. When the form has many fields, nested or dynamic arrays, async validation and touched/dirty state, I would consider a library like React Hook Form, which uses uncontrolled inputs and refs to avoid a re-render for every keystroke. I would not add a library before I feel that pain, because the platform plus React 19 actions covers a lot.

Key takeaways

  • A controlled input has value and onChange, with React state as the truth. An uncontrolled input has defaultValue and is read with FormData or a ref. Start with useState(""), never undefined.
  • onChange fires on every keystroke. Events bubble from child to parent, and stopPropagation stops them.
  • <button> in a form submits by default. Use type="button" for other buttons.
  • In React 19, <form action={fn}> calls your function with FormData inside a transition. useActionState gives you state, the action to pass, and isPending. useFormStatus gives a child button the pending flag. useOptimistic shows the expected result early.
  • React 19 resets uncontrolled fields after an action, so keep the typed values on error.
  • Validate on the server as well. Use labels, aria-invalid, aria-describedby and role="alert".

What's next

Next: Data Fetching, Caching and Server State, lesson 5. A form usually ends with a request to a server. Lesson 5 covers fetching in effects and its bugs, loading and error states, race conditions again, why libraries like TanStack Query exist, and Suspense with use() in more depth. Practise now: build a signup form with useActionState that validates on a fake server and keeps the typed values on error.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series