Forms

A form is the one place where the browser already has state of its own. Every <input> keeps a value, every <form> can serialize itself into FormData, and the platform validates required fields without a line of JavaScript. React can take all of that over, hand all of it back, or split the difference — and picking the wrong split is the usual reason a login screen turns into four hundred lines.React 19 changed the arithmetic. Passing a function to a form's action prop makes React call it with the submitted FormData, wrapped in a transition, with the fields reset afterwards; useActionState, useFormStatus and useOptimistic then give you the pending flag, the returned result and an instant preview for free. Three years ago every one of those needed a state variable and a try/finally. Approach Where the value lives Best for Controlled input React state Live formatting, dependent fields Uncontrolled input The DOM node Large forms, file inputs Form action + FormData The DOM, read on submit Submit-once forms of any size Table 2.15: Three ways to get a value out of a form

2.9.1 Controlled and Uncontrolled Inputs

An input is controlled when you pass it a value (or checked) prop and an onChange handler: React renders the value from state, the handler writes the next value back, and the DOM node is only ever a mirror. Pass value without onChange and you get a field the user cannot type in, plus a console warning — a fixed value is a read-only field, not a starting value. Pass defaultValue instead and the input is uncontrolled: the DOM owns the text and you read it on submit. JSX also smooths over two DOM inconsistencies: <textarea> takes a value prop rather than children, and <select> takes value rather than a selected attribute on one <option>.

<div id="root"></div>

<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>

<script type="importmap">{ "imports": { "react": "https://esm.sh/react@19.3.0",

"react-dom/client": "https://esm.sh/react-dom@19.3.0/client" } }</script>

<script type="text/babel" data-type="module">

import React, { useState } from 'react';

import { createRoot } from 'react-dom/client';

function Rsvp() {

const [form, setForm] = useState({ going: true, flavor: 'lime', note: 'Bringing cake' });

function handleChange(e) {

const t = e.target; // one handler for every field

setForm({ ...form, [t.name]: t.type === 'checkbox' ? t.checked : t.value });

}

return (

<form style={{ font: '15px system-ui' }}>

<label><input name="going" type="checkbox" checked={form.going}

onChange={handleChange} /> Going</label>{' '}

<select name="flavor" value={form.flavor} onChange={handleChange}>

<option value="lime">Lime</option><option value="mango">Mango</option>

</select>

<textarea name="note" rows="2" value={form.note} onChange={handleChange} />

<pre>{JSON.stringify(form)}</pre>

</form>

);

}

createRoot(document.getElementById('root')).render(<Rsvp />);

</script>

Listing 2.68: A controlled form mirroring its own state Figure 2.34: Browser output of Listing 2.68The computed property name [t.name] is what keeps this to one handler: name each field after its state key and the handler never grows. Reach for controlled inputs when something must react to every keystroke — formatting a phone number, filtering a list, disabling a second field based on a first. The cost is a re-render of the whole form per keystroke, visible in a fifty-field form; that is why uncontrolled inputs and the form actions of Section 2.9.2 scale better. File inputs are always uncontrolled, since value on <input type="file"> is read-only. And never let an input switch from uncontrolled to controlled mid-life — state initialized to undefined and filled from a fetch does exactly that, warns, and can lose the user's typing.

2.9.2 Form Actions

In React 19 the action prop of a <form> accepts a function instead of a URL. React intercepts the submit, builds a FormData from the fields, calls your function with it, and — if the function returns without throwing — resets the form. The call runs inside a transition, so the UI stays responsive and the pending state is visible to useFormStatus. <button> and <input type="submit"> take a matching formAction prop, which lets one form route to different handlers.

function Subscribe() {

const [saved, setSaved] = useState(null);

async function subscribe(formData) { // React hands you the FormData

await fetch('https://api.example.com/subscribe', { method: 'POST', body: formData });

setSaved(formData.get('email')); // the fields reset themselves

}

return (

<form action={subscribe}>

<input name="email" type="email" required />

<button>Subscribe</button>

{saved && <p>Subscribed {saved}.</p>}

</form>);

}

Listing 2.69: A form action reading FormData Note what is absent: no onSubmit, no event.preventDefault(), no state variable per field. Every input needs a name, because name is the FormData key — and FormData goes to fetch directly, which is how file uploads come along for free. The reset happens only on success, so a handler that throws leaves the user's typing in place, and noValidate on the <form> turns off the browser's own pass when you want to own the messages.

2.9.3 useActionState

useActionState(action, initialState) wraps an action so React tracks its result and its pending flag for you, returning [state, formAction, isPending]. Your action is called with (previousState, formData) and returns the next state — a reducer, except that this one may be async and may perform side effects. Pass formAction to the form's action prop; state is whatever the last call returned, which makes it the natural home for a validation error or a success message.

<div id="root"></div>

<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>

<script type="importmap">{ "imports": { "react": "https://esm.sh/react@19.3.0",

"react-dom/client": "https://esm.sh/react-dom@19.3.0/client" } }</script>

<script type="text/babel" data-type="module">

import React, { useActionState } from 'react';

import { createRoot } from 'react-dom/client';

async function updateName(previous, formData) {

const name = formData.get('name').trim();

await new Promise(r => setTimeout(r, 400)); // simulate a slow server

if (!name) return { ...previous, error: 'Name cannot be empty.' };

return { name, error: null };

}

const initial = { name: 'Guest', error: null };

function App() {

const [state, formAction, isPending] = useActionState(updateName, initial);

return (

<form action={formAction} style={{ font: '15px system-ui' }}>

<p>Current name: <b>{state.name}</b></p>

{state.error && <p style={{ color: 'crimson' }}>{state.error}</p>}

<input name="name" disabled={isPending} />

<button disabled={isPending}>{isPending ? 'Saving...' : 'Save'}</button>

</form>

);

}

createRoot(document.getElementById('root')).render(<App />);

setTimeout(() => { document.querySelector('input').value = 'Ada';

document.querySelector('button').click(); }, 300);

</script>

Listing 2.70: Threading a result and an error through useActionState Figure 2.35: Browser output of Listing 2.70Returning an error object instead of throwing is the pattern to learn: a thrown error reaches the nearest error boundary and takes the form down with it, while a returned { error } renders next to the field. Submit again while a call is in flight and React queues the calls, passing each one the previous result. React 19 canaries named this Hook useFormState and exported it from react-dom; that is the same Hook under its old name.

2.9.4 useFormStatus

useFormStatus reports on the most recent submission of the nearest parent <form>. It is imported from react-dom, not react, takes no arguments, and returns { pending, data, method, action }, where data is the FormData being submitted. Its one rule catches everybody once: calling it in the component that renders the <form> always returns pending: false, because from there no parent form exists. Call it from a child.

import { useFormStatus } from 'react-dom';

export function SubmitButton({ children }) {

const { pending } = useFormStatus(); // reads the nearest ANCESTOR <form>

return <button disabled={pending}>{pending ? 'Working...' : children}</button>;

}

// Usage — SubmitButton must be *inside* the form element:

// <form action={save}><input name="q" /><SubmitButton>Save</SubmitButton></form>

Listing 2.71: A reusable submit button that disables itself That constraint is a feature. Because the button reads the status from context rather than from props, one SubmitButton serves every form in the application and no parent threads an isPending prop down. Use data for a live preview — data?.get('title') renders what is being saved while it is being saved — remembering that data is null whenever nothing is pending.

2.9.5 useOptimistic

useOptimistic(realState, updateFn) returns [optimisticState, addOptimistic]. Render optimisticState: it equals the real state when nothing is pending, and shows your predicted value while an action is in flight. addOptimistic must be called inside a transition — in practice, inside a form action — and React drops the optimistic value automatically when the action settles, with no extra render.

<div id="root"></div>

<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>

<script type="importmap">{ "imports": { "react": "https://esm.sh/react@19.3.0",

"react-dom/client": "https://esm.sh/react-dom@19.3.0/client" } }</script>

<script type="text/babel" data-type="module">

import React, { useState, useOptimistic } from 'react';

import { createRoot } from 'react-dom/client';

function Chat() {

const [messages, setMessages] = useState(['Hi there!']);

const [shown, addOptimistic] = useOptimistic(messages,

(current, text) => [...current, text + ' (sending...)']);

async function send(formData) {

const text = formData.get('message');

addOptimistic(text); // instant, still inside a transition

await new Promise(r => setTimeout(r, 9000));

setMessages(prev => [...prev, text]); // the real state catches up

}

return (

<div style={{ font: '15px system-ui' }}>

<ul>{shown.map((m, i) => <li key={i}>{m}</li>)}</ul>

<form action={send}><input name="message" /> <button>Send</button></form>

</div>

);

}

createRoot(document.getElementById('root')).render(<Chat />);

setTimeout(() => { document.querySelector('input').value = 'On my way';

document.querySelector('button').click(); }, 200);

</script>

Listing 2.72: Showing a message before the server has it Figure 2.36: Browser output of Listing 2.72The capture above is taken mid-flight, which is why the second message still carries its label; once setMessages runs, the optimistic entry and the real one converge in a single render. The update function receives (currentState, optimisticValue) and is re-run against fresh data if the real state changes while the action is pending, so the preview never goes stale. If the action fails, do nothing: the optimistic value disappears on its own.

2.9.6 Validation Strategies

Three layers earn their place. The browser's own constraint validation — required, type="email", min, maxLength, pattern — costs nothing, is accessible by default, and blocks the submit before React sees it. Client-side schema validation gives precise, per-field messages. Server-side validation is the only layer that is a guarantee, because a client can be scripted.Timing matters as much as the rules. Validating on every keystroke tells users they are wrong while they are still typing; validating on blur, then re-validating on change only for fields already marked invalid, is what React Hook Form does by default. One schema keeps the layers honest by being shared: Zod 4.6.5 (github.com/colinhacks/zod (https://github.com/colinhacks/zod), MIT, npm install zod) is the common choice, with Yup 1.7.1 and Valibot 1.5.0 in the same slot.

import { z } from 'zod';

const Signup = z.object({

email: z.email('Enter a valid email address.'),

age: z.coerce.number().int().min(18, 'You must be 18 or older.')

});

export async function signup(previous, formData) {

const parsed = Signup.safeParse(Object.fromEntries(formData));

if (!parsed.success) // render these next to the fields

return { errors: z.flattenError(parsed.error).fieldErrors };

await fetch('https://api.example.com/signup', { method: 'POST', body: formData });

return { errors: {}, ok: true };

}

Listing 2.73: Validating a form action against a Zod schema Object.fromEntries(formData) turns the form into a plain object, and safeParse returns a result instead of throwing — exactly the shape useActionState wants. Import the same module on the server and you have one definition of "valid" for both sides. Announce errors with aria-invalid="true" plus an aria-describedby pointing at the message element, or a screen-reader user will never hear them.

2.9.7 Formik

Formik (github.com/jaredpalmer/formik (https://github.com/jaredpalmer/formik), Apache-2.0, npm install formik yup) was the form library for years and still fills a great deal of production code. It keeps values, errors and touched for you, runs a validation schema, and exposes the result through a render prop or through useFormik. Version 2.4.9 dates from November 2025; the project is maintained rather than developed, so treat it as a library you inherit, not one you start with.

import { Formik, Form, Field, ErrorMessage } from 'formik';

import * as Yup from 'yup';

const schema = Yup.object({ email: Yup.string().email().required('Email is required') });

const send = (values) => fetch('https://api.example.com/contact', { method: 'POST' });

export default function Contact() {

return (

<Formik initialValues={{ email: '' }} validationSchema={schema} onSubmit={send}>

{({ isSubmitting }) => (

<Form>

<Field name="email" type="email" />

<ErrorMessage name="email" component="p" />

<button disabled={isSubmitting}>Send</button>

</Form>)}

</Formik>);

}

Listing 2.74: A Formik form validated by a Yup schema <Field> wires value, onChange and onBlur by name; <Form> is a <form> with onSubmit already bound. Everything lives in React state, so every keystroke re-renders the subtree under <Formik> — the reason <FastField> exists, and the reason Section 2.9.9 exists.

2.9.8 Field Arrays

A field array is a repeating group: a list of friends, line items on an invoice, phone numbers. The awkward part is not rendering it but keeping field names, validation errors and touched flags aligned as rows are added and removed. Formik's <FieldArray> takes the name of an array inside values and hands you helpers — push, remove, insert, swap, move — that update the array and fix up errors and touched with it.

<Formik initialValues={{ friends: ['jared', 'ian'] }} onSubmit={save}>

{({ values }) => (

<Form>

<FieldArray name="friends">

{(helpers) => values.friends.map((friend, i) => (

<div key={i}>

<Field name={`friends.${i}`} />

<button type="button" onClick={() => helpers.remove(i)}>-</button>

<button type="button" onClick={() => helpers.insert(i, '')}>+</button>

</div>))}

</FieldArray>

<button type="submit">Save</button>

</Form>)}

</Formik>

Listing 2.75: Adding and removing rows with FieldArray Two details decide whether this works. Field names are dotted paths — friends.0, or contacts.2.phone for an array of objects — and type="button" on the add and remove buttons is mandatory, since a bare <button> inside a form submits it. The array index is a safe key only while rows are edited in place; once they can be reordered, store a generated id per row. React Hook Form's useFieldArray supplies those ids as field.id.

2.9.9 React Hook Form

React Hook Form (github.com/react-hook-form/react-hook-form (https://github.com/react-hook-form/react-hook-form), MIT, npm install react-hook-form) is the default choice today — version 7.88.0, September 2026, and roughly forty million npm downloads a week against Formik's three million. Its trick is to keep field values in refs rather than in React state, so typing re-renders nothing at all; only a validation error or a watched field causes a render. register('name') returns the ref, name, onChange and onBlur props to spread on an input.

import { useForm } from 'react-hook-form';

export default function Signup() {

const { register, handleSubmit, formState: { errors, isSubmitting } } =

useForm({ mode: 'onTouched' }); // validate on blur, then on change

const onSubmit = async (values) => console.log(values);

return (

<form onSubmit={handleSubmit(onSubmit)}>

<input {...register('email', { required: 'Email is required' })}

aria-invalid={Boolean(errors.email)} />

{errors.email && <p role="alert">{errors.email.message}</p>}

<button disabled={isSubmitting}>Sign up</button>

</form>

);

}

Listing 2.76: React Hook Form keeps values out of React state Swap the inline rules for a schema with @hookform/resolvers 5.9.1 — useForm({ resolver: zodResolver(schema) }) — and one Zod object validates client and server. Components that hide their input, such as a date picker or a Material UI field, cannot take a spread ref; wrap those in <Controller>. useFieldArray covers the repeating rows of Section 2.9.8 and supplies a stable field.id for the React key.

2.9.10 Downshift

An autocomplete is a form control the platform does not provide, and the WAI-ARIA combobox pattern it must follow is long: roles on four elements, aria-expanded, aria-activedescendant, arrow-key and Escape handling, focus that never leaves the input. Downshift (github.com/downshift-js/downshift (https://github.com/downshift-js/downshift), MIT, npm install downshift, 9.4.0) implements that logic and renders nothing. Its Hooks — useCombobox, useSelect and useMultipleSelection — return prop getters you spread onto your own markup, so the styling stays yours.

import { useState } from 'react';

import { useCombobox } from 'downshift';

const fruits = ['Apple', 'Apricot', 'Banana', 'Blueberry', 'Cherry'];

export default function FruitPicker() {

const [items, setItems] = useState(fruits);

const cb = useCombobox({ items, onInputValueChange: ({ inputValue }) =>

setItems(fruits.filter(f => f.toLowerCase().startsWith(inputValue.toLowerCase()))) });

return (

<div>

<label {...cb.getLabelProps()}>Fruit</label>

<input {...cb.getInputProps()} />

<ul {...cb.getMenuProps()}>

{cb.isOpen && items.map((item, index) => (

<li key={item} {...cb.getItemProps({ item, index })}

aria-current={cb.highlightedIndex === index}>{item}</li>))}

</ul></div>);

}

Listing 2.77: An accessible combobox with useCombobox Spread every getter on the element it names: getMenuProps must land on the <ul> even when the menu is closed, or Downshift warns and screen readers lose the relationship to the input. Pass your own handler through the getter — getInputProps({ onKeyDown: mine }) — so Downshift runs its handler first and yours second. Use itemToString when the items are objects, and capture the choice with onSelectedItemChange.