Lesson 8 · Forms
Forms and React 19 Actions
Controlled vs. uncontrolled, useActionState, useFormStatus and useOptimistic.
Loading lesson…
Lesson 8 · Forms
Controlled vs. uncontrolled, useActionState, useFormStatus and useOptimistic.
Loading lesson…
At the council office you can fill in a form in two ways. Either you take it to a table, fill it in calmly and hand it in at the counter – the clerk checks it only there. Or you sit down with a clerk who looks over your shoulder and comments on every box straight away: "the date goes day, month, year", "the postcode is missing here". The second way is more comfortable for you, but it costs the clerk more work.
In React these are the two kinds of form fields:
When you hand the form in, the second half of the story begins. The clerk takes it, a "processing" sign lights up above the counter, and a moment later you get the form back – either stamped, or with notes on what to fix. All of this (waiting, the result, errors) we used to write by hand in React over and over. React 19 has Actions for it – a built-in front desk.
A field for a discount code in the format ABC-123. We want the letters to turn into capitals by themselves and to see right away whether the code is valid:
function CouponField() {const [code, setCode] = useState('')const valid = /^[A-Z]{3}-\d{3}$/.test(code)return (<><inputvalue={code}onChange={(e) => setCode(e.target.value.toUpperCase().slice(0, 7))}/><span>{valid ? '✓ A valid code' : 'Format: ABC-123'}</span></>)}
onChange gets "a", turns it into "A" and stores it in state.value="A". There's a capital A in the field – even though you typed a small one.valid is calculated and the message under the field updates.Because every letter passes through you, you can adjust it, limit it and check it straight away. The price is a render on every keystroke – negligible for a few fields.
A form with a city and a T-shirt size. We check nothing along the way, we need the values only on submit:
function ShippingForm() {function handleSubmit(e: React.FormEvent<HTMLFormElement>) {e.preventDefault()const data = new FormData(e.currentTarget)console.log(data.get('city'), data.get('size'))}return (<form onSubmit={handleSubmit}><input name="city" defaultValue="London" /><select name="size" defaultValue="M">…</select><button>Submit</button></form>)}
useState. The browser holds the value, just like in plain HTML.defaultValue is only the initial value. React sets it once and then doesn't look after the field.FormData by the name attribute.| Controlled | Uncontrolled | |
|---|---|---|
| Where the value is | In React’s state (value + onChange) | In the browser (defaultValue, read through FormData) |
| Render while typing | On every character | None |
| Good for | Live validation, formatting, dependent fields, a disabled button | Simple forms, submitting through an action, big forms |
What you see: On the left, the discount code field from the first example (controlled). On the right, the form with a city and a size from the second example (uncontrolled).
Try it:
abc-12 in small letters into the left field. The letters turn into capitals at once and the field is red. Add 3 – the message changes to "✓ A valid code".FormData.The takeaway: You can steer a controlled field on every letter. An uncontrolled one is simpler and is enough where you need the value only on submit.
Loading the interactive part…
This is how a form that submits to a server used to be written. Notice how much of the code has nothing to do with signing up itself:
function Signup() {const [isPending, setIsPending] = useState(false)const [error, setError] = useState<string | null>(null)async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {e.preventDefault() // prevent a page reloadif (isPending) return // protection against a double clicksetIsPending(true)setError(null)try {await registerUser(Object.fromEntries(new FormData(e.currentTarget)))e.currentTarget.reset() // clear the form} catch (err) {setError((err as Error).message)} finally {setIsPending(false)}}// …}
In React 19 you can pass a function to <form action>. React calls it with FormData, keeps track itself of whether it's still running, and clears the form when it finishes. Plus two helpers:
interface FormState {errors?: { name?: string; email?: string }message?: string}// An action: gets the previous result and the filled-in form, returns a new resultasync function signup(prev: FormState, formData: FormData): Promise<FormState> {const name = String(formData.get('name')).trim()const email = String(formData.get('email')).trim()if (name.length < 2) return { errors: { name: 'The name must have at least 2 characters' } }if (!email.includes('@')) return { errors: { email: 'An invalid e-mail' } }try {const user = await registerUser({ name, email })return { message: `Welcome, ${user.name}!` }} catch (e) {return { errors: { email: (e as Error).message } }}}function Signup() {const [state, formAction, isPending] = useActionState(signup, {})return (<form action={formAction}><input name="name" />{state.errors?.name && <p>{state.errors.name}</p>}<input name="email" />{state.errors?.email && <p>{state.errors.email}</p>}<SubmitButton />{state.message && <p>{state.message}</p>}</form>)}
What happens after clicking "Sign up":
preventDefault) and calls signup(previousState, formData).isPending is true – the "processing" sign above the counter is lit.state and the component re-renders.useActionState is really a useReducer for forms: the action gets the previous state and an "event" (the filled-in form) and returns a new state – it's just allowed to be asynchronous.
import { useFormStatus } from 'react-dom'function SubmitButton() {const { pending } = useFormStatus()return <button disabled={pending}>{pending ? 'Signing up…' : 'Sign up'}</button>}
useFormStatus reads the status of the nearest parent <form>. Only whoever stands at the counter sees the sign – that's why it must be in a component inside the form, not in the one that renders the <form>. The reward is a button you can drop into any form and that knows by itself that it's being submitted.
What you see: The sign-up form from the example. The action validates, calls the fake API (it takes about a second) and returns errors or a message. The button is a separate component with useFormStatus. At the bottom you can see the value of isPending.
Try it:
@taken.com. While waiting, the button switches to "Signing up…" and isPending to true. Then an error from the server appears.The takeaway: Waiting, errors, double-click protection and the reset are handled by React. You write only what matters: what should happen with the form.
Loading the interactive part…
A waiter writes your order on the bill as soon as you say it – they don't wait for the kitchen to confirm the ingredients are there. In most cases they are. And when something occasionally runs out, the waiter comes back and crosses the item off. For you, that's much nicer than waiting at the table for every item to be confirmed.
Optimistic UI works the same way. For actions that almost always succeed (a like, adding to the cart, sending a message), you show the result right away and the server confirms it in the background. If it fails, the change is reverted.
function LikeButton() {const [likes, setLikes] = useState(10) // confirmed by the serverconst [optimisticLikes, addOptimistic] = useOptimistic(likes,(current, delta: number) => current + delta, // how to "guess" the result)function like() {startTransition(async () => {addOptimistic(1) // 1) show +1 right awaytry {await likePost(1) // 2) wait for the serversetLikes((l) => l + 1) // 3a) confirm the real state} catch {// 3b) set nothing – the guess disappears by itself when the action ends}})}return <button onClick={like}>❤️ {optimisticLikes}</button>}
| Moment | likes (reality) | optimisticLikes (shown) |
|---|---|---|
| Before the click | 10 | 10 |
| Right after the click | 10 | 11 |
| The server confirmed | 11 | 11 |
| …or the server failed | 10 | 10 – the guess disappeared |
useOptimistic keeps the guess only while the action is running (hence startTransition – in a form action it's automatic). When the action ends, the real state is shown again – confirmed, or the original.
What you see: A button with the number of likes and next to it the number confirmed by the server. The server replies in 0.7 s and fails 25 % of the time.
Try it:
The takeaway: The user sees the result immediately. The real state changes only according to the server's reply, and on an error the guess reverts by itself.
Loading the interactive part…
For forms with dozens of fields, dynamic rows and complex validation, reach for React Hook Form (fast thanks to uncontrolled fields) or TanStack Form. Describe the validation with a schema in Zod or Valibot – the same schema then checks the data on the server too, and TypeScript derives the type from it:
const schema = z.object({email: z.string().email('An invalid e-mail'),password: z.string().min(8).regex(/\d/, 'At least one digit'),})type FormValues = z.infer<typeof schema> // the type for free from the schema
Connect the input to the state – two attributes.
CHALLENGE: A controlled field
The `name` state is ready, but the input isn't connected to it.
1. Give the input value={name}
2. and onChange={(e) => setName(e.target.value)}
The greeting under the field will then change as you type.row, stack, card, list, btn, input or muted are the course's ready-made styles in src/styles.css (row = side by side, stack = stacked, card = bordered box, muted = grey text).Loading the interactive part…
The first challenge is a classic controlled form (section 1) with good UX: errors show up only after you leave a field. The second combines sections 2 and 3 – a form without a single onSubmit and a comment that appears before the server saves it.
The clerk over your shoulder, but a considerate one: they don't point out an error before you leave the field.
Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.
A modern form without a single onSubmit and without a hand-written loading state.
Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.
<form action={fn}> handles waiting, double clicks and the reset.useActionState returns the action's result (errors, messages), useFormStatus is the sign for buttons inside the form.