MENU
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.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>
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>);
}
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>
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>
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>
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 };
}
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>);
}
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>
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>
);
}
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>);
}