A form should tell you whether it is sending, has succeeded or has failed. In React 19, useActionState lets you bring an action’s result and pending state together. You still need to decide what the person sees in each case.
Try the failure case first
Enter a project name and check “Simulate an error”. After submitting, the text stays in the field. Uncheck the box and try again. The response takes a little time on purpose so you can see the pending state. Everything happens in your browser, with no request or saved data.
The component used in the demo
The action’s first argument receives the previous state. The second receives FormData. The third value returned by the hook, namedpendinghere, disables the controls while processing.
import { useLocale } from "../../lib/locale";
import { useActionState, useState } from "react";
type State = { message: string; invalid: boolean; received?: string };
export default function FormDemo() {
const { t, locale } = useLocale();
const [name, setName] = useState("");
const [simulateError, setSimulateError] = useState(false);
const [state, action, pending] = useActionState(
async (_previous: State, data: FormData): Promise<State> => {
const value = String(data.get("project") ?? "").trim();
if (value.length < 3) {
return { message: "Écris au moins 3 caractères.", invalid: true };
}
// Simulation locale. Aucun appel réseau et aucune sauvegarde.
await new Promise((resolve) => setTimeout(resolve, 800));
if (simulateError) {
return {
message: "L’envoi a échoué. Tu peux réessayer.",
invalid: false,
};
}
setName("");
return {
message: "",
received: value,
invalid: false,
};
},
{ message: "Aucune donnée n’est enregistrée.", invalid: false },
);
return (
<div className="form-demo">
<label className="form-demo-checkbox">
<input
type="checkbox"
name="simulate-error"
checked={simulateError}
onChange={(event) => setSimulateError(event.target.checked)}
disabled={pending}
/>
{t("Simuler une erreur")}
</label>
<form
action={action}
className="contents"
aria-label={t("Essayer le formulaire")}
>
<label htmlFor="demo-project">{t("Nom du projet")}</label>
<input
id="demo-project"
name="project"
value={name}
onChange={(event) => setName(event.target.value)}
disabled={pending}
maxLength={80}
aria-invalid={state.invalid}
aria-describedby="form-demo-status"
placeholder={t("Mon prochain projet")}
/>
<button type="submit" disabled={pending}>
{pending ? t("Envoi en cours…") : t("Essayer l’envoi")}
</button>
<p id="form-demo-status" role="status" aria-live="polite">
{pending
? t("La réponse arrive…")
: state.received
? locale === "en"
? `“${state.received}” was received in the demo.`
: `« ${state.received} » a bien été reçu dans la démo.`
: t(state.message)}
</p>
</form>
</div>
);
}
Only clear the field after success
The field is controlled with useState. The function only clears it after a successful result. On failure, it returns a message and keeps the input. The simulation checkbox is controlled too, so its state does not change during the test. That demo setting sits outside the form, so the form’s native reset does not affect it.
The result appears in a region announced to screen readers. A colour change alone would not be enough. The button label also shows that the action is running, without depending on animation.
What the server still needs to do
To connect a real service, replace the simulation with a call to your server route. Validate data and permissions on the server even if the browser has already checked them. Return a clear message on failure and keep technical details in logs that contain no sensitive data.
A disabled button limits double clicks. It does not guarantee that an operation runs only once. For a sensitive creation, use an idempotency key and a storage constraint. Two tabs or a replayed request can easily bypass the button.
Before shipping, try empty input, a value that is too short, a failure followed by success and keyboard navigation. Those few cases already tell you a lot about the form.