React 19: form Actions, explicit errors, and a staged upgrade

Use a client form Action without losing the entered value after a failed save. Reproduce pending states, reset behavior and duplicate-submission limits.
Keep a failed save editable
React 19 became stable on 5 December 2024. Its form Actions connect an asynchronous operation to pending and returned state. This example focuses on an edit form: the entered name should survive a failed save so the user can correct it or retry.
The example runs entirely in the browser. NameForm receives saveName from its host. The supplied demo adapter simulates outcomes and stores nothing. This is a historical React 19.0.0 reproduction for developers who already use controlled inputs and promises. It does not implement a Server Function, authorization or persistence.
Return expected errors and keep input state separate
useActionState passes the previous result as its first argument and the form payload as its second. Passing formAction to the form establishes the Action context. The fixture deliberately removes the previous-state parameter: the handler then treats the initial state object as FormData, and the error boundary reports that get is not a function.
The returned state controls the message. The draft controls the input. While a save is pending, the field is read-only and the submit button is disabled. The previous message is hidden until the new result arrives. This keeps the displayed value aligned with the submitted value because the user cannot edit it during that request. The tradeoff is that editing must wait for the host promise to settle.
The demo accepts non-empty trimmed text with a limit of eighty UTF-16 code units. It is not a policy for real people’s names. The previous two-character message was wrong: a single emoji occupied two units and passed. The revised example accepts that emoji as non-empty text without claiming it contains two characters.
"use client";
import { useActionState, useState } from "react";
export default function NameForm({ saveName }) {
const [draft, setDraft] = useState("");
const [state, formAction, pending] = useActionState(
async (_previous, formData) => {
const name = String(formData.get("name") ?? "").trim();
if (!name) return { message: "Enter a display name." };
if (name.length > 80) return { message: "That name is too long." };
try {
const result = await saveName(name);
if (!result.ok) return { message: "The name could not be saved." };
return { message: "Name saved." };
} catch {
return { message: "Unable to save. Please try again." };
}
},
{ message: "" }
);
return (
<form action={formAction}>
<label>
Display name
<input name="name" required maxLength={80} readOnly={pending}
value={draft} onChange={(event) => setDraft(event.target.value)} />
</label>
<button type="submit" disabled={pending}>
{pending ? "Saving…" : "Save name"}
</button>
<p role="status">{pending ? "" : state.message}</p>
</form>
);
}
Separate demonstration outcomes from storage
A normal value makes the demo resolve with ok true. The value reserved returns ok false, while offline rejects. Each outcome is checked with the exact adapter below. Its delay is artificial. No network request or database write occurs, so Name saved describes the simulated result only.
The browser fixture also supplies a held promise to inspect pending state deterministically. Keyboard submission starts one call, the input becomes read-only, and another Enter press does not start a second native submission. After success, the input is editable again. Handled failure and caught rejection both retain the entered value, and a later retry succeeds.
import NameForm from "./NameForm.jsx";
async function demoSaveName(name) {
await new Promise((resolve) => setTimeout(resolve, 600));
if (name === "offline") throw new Error("Simulated connection failure");
return { ok: name !== "reserved" };
}
export default function NameFormDemo() {
return <NameForm saveName={demoSaveName} />;
}
A resolved error result still triggers form reset
An error-shaped return value does not reject an Action. With uncontrolled input, both the handled failure and the caught rejection in this example resolve with a message and clear the field. The pre-revision reproduction captured that behavior. Keeping value and onChange connected to draft preserves the text when React resets the form.
The negative control removes that controlled binding and reproduces the empty field after both error paths. I prefer retaining the value here because this is an edit form. Automatic clearing can make sense for a successfully submitted new-message form. The choice should follow the interaction, not the shape of the returned object.
The disabled button is not duplicate protection. The fixture calls form.requestSubmit() programmatically while the button is disabled, then observes another queued save after the first completes. Removing the disabled binding also lets a repeated click queue the same name twice. The host must enforce whatever duplicate-request policy the real mutation requires.
Reproduce the boundaries, then integrate the host
Download the source and tests, extract the archive, and run python3 frontend/scripts/verify-react-19.py from its root with Docker running and Python 3 installed. Image construction needs network access. Execution uses a loopback server inside a container without external network, host-directory mounts or credentials.
The expected result contains fourteen checks and three deliberately broken variants. The exact components compile with React and React DOM 19.0.0, Node 24.20.0 and Chromium 153.0.8010.12. Tests cover native required validation, whitespace-only text, Unicode, the eighty-unit boundary and a longer value after bypassing the browser’s field limit. This is reproducible historical behavior, not a recommendation to deploy the original release.
The host contract is narrow: saveName(name) resolves to an object with an ok boolean or rejects. The component has no timeout or cancellation. If the promise never settles, the form stays read-only. A production integration needs an explicit policy for that failure and must validate and authorize the mutation at its actual server boundary.
I recommend upgrading dependencies and checking existing flows before adopting this form pattern. The official upgrade guide covers compatibility work that this small browser fixture cannot establish. It does not verify Server Functions, progressive enhancement without JavaScript, optimistic reconciliation, document resources or a framework’s persistence behavior.
Reproduce the examples
Exact form and demo, dependency lock, pinned browser, fourteen checks and three deliberate failure variants.
Download the source and tests (.zip)
