XState Form Validation Machine
How do you express a form with a debounced username availability check, a submission that can be retried, and field errors from both the browser and the server as one declarative XState machine — and still let the browser’s Constraint Validation API focus and announce errors? This recipe builds it with XState 5’s setup() API: typed context and events, guards for validity and retry limits, a delayed transition that is the debounce, invoked promise actors that are cancelled automatically when their state exits, and a small DOM binding that mirrors context errors into setCustomValidity() and calls reportValidity().
When XState Is Worth It
A hand-written reducer, as in modelling form submission with a finite state machine, is enough for a single submission flow. XState starts paying off when the form has:
- Concurrent concerns — a field-level async check running while the rest of the form is edited — which map to parallel states.
- Time-based behaviour — debounce, timeouts, “retry in 10 seconds” — which XState expresses as delayed transitions instead of scattered timers.
- Async work that must be cancelled — invoked actors stop automatically when the state that invoked them is exited.
- Reviewers who benefit from a diagram — the machine definition can be visualised directly.
The concepts are the same as in the form state machines topic; XState adds a runtime that enforces them.
Minimal Working XState Machine
import { setup, assign, fromPromise, createActor } from "xstate";
type Errors = Partial<Record<"username" | "email", string>>;
interface Context {
username: string;
errors: Errors; // client + server field errors
attempt: number;
}
type Events =
| { type: "USERNAME.CHANGE"; value: string }
| { type: "SUBMIT"; formData: FormData; nativeErrors: Errors }
| { type: "RETRY"; formData: FormData };
export const formMachine = setup({
types: { context: {} as Context, events: {} as Events },
actors: {
checkUsername: fromPromise<boolean, { username: string }>(async ({ input, signal }) => {
const res = await fetch(`/api/usernames/available?u=${encodeURIComponent(input.username)}`, { signal });
if (!res.ok) throw new Error("unavailable");
return (await res.json()).available as boolean;
}),
submitForm: fromPromise<{ ok: true } | { ok: false; errors: Errors }, { formData: FormData }>(async ({ input, signal }) => {
const res = await fetch("/api/signup", { method: "POST", body: input.formData, signal, headers: { accept: "application/json" } });
if (res.status === 422) return { ok: false, errors: (await res.json()).errors };
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return { ok: true };
}),
},
guards: {
nativeValid: ({ event }) => event.type === "SUBMIT" && Object.keys(event.nativeErrors).length === 0,
usernameLongEnough: ({ context }) => context.username.trim().length >= 3,
canRetry: ({ context }) => context.attempt < 3,
},
delays: { debounce: 400 },
}).createMachine({
id: "signup",
type: "parallel",
context: { username: "", errors: {}, attempt: 0 },
states: {
username: {
initial: "idle",
on: {
"USERNAME.CHANGE": {
target: ".debouncing",
actions: assign(({ context, event }) => ({ username: event.value, errors: { ...context.errors, username: undefined } })),
},
},
states: {
idle: {},
debouncing: { after: { debounce: [{ guard: "usernameLongEnough", target: "checking" }, { target: "idle" }] } },
checking: {
invoke: {
src: "checkUsername",
input: ({ context }) => ({ username: context.username }),
onDone: [
{ guard: ({ event }) => event.output, target: "available" },
{ target: "taken", actions: assign(({ context }) => ({ errors: { ...context.errors, username: "That username is taken." } })) },
],
onError: { target: "idle" }, // unknown: let the server decide on submit
},
},
available: {},
taken: {},
},
},
submission: {
initial: "editing",
states: {
editing: {
on: {
SUBMIT: [
{ guard: "nativeValid", target: "sending", actions: assign({ attempt: 1 }) },
{ actions: assign(({ context, event }) => ({ errors: { ...context.errors, ...event.nativeErrors } })) },
],
},
},
sending: {
invoke: {
src: "submitForm",
input: ({ event }) => ({ formData: (event as Extract<Events, { formData: FormData }>).formData }),
onDone: [
{ guard: ({ event }) => event.output.ok, target: "done" },
{ target: "editing", actions: assign(({ event }) => ({ errors: (event.output as { errors: Errors }).errors })) },
],
onError: { target: "failed" },
},
},
failed: {
on: { RETRY: { guard: "canRetry", target: "sending", actions: assign(({ context }) => ({ attempt: context.attempt + 1 })) } },
},
done: { type: "final" },
},
},
},
});
The debounce deserves attention: there is no setTimeout anywhere. debouncing has an after transition, and every USERNAME.CHANGE re-enters debouncing, which restarts the timer. When the region leaves checking for any reason — a new keystroke — XState stops the invoked checkUsername actor and aborts its signal, so a stale availability answer can never arrive. That is the same guarantee cancelling stale requests with AbortController builds by hand.
// DOM binding: native checks in, context errors out through setCustomValidity + reportValidity.
const form = document.querySelector<HTMLFormElement>("#signup")!;
const actor = createActor(formMachine).start();
form.querySelector<HTMLInputElement>("[name=username]")!
.addEventListener("input", (e) => actor.send({ type: "USERNAME.CHANGE", value: (e.target as HTMLInputElement).value }));
form.addEventListener("submit", (e) => {
e.preventDefault();
const nativeErrors: Errors = {};
for (const el of form.querySelectorAll<HTMLInputElement>("[name]")) {
el.setCustomValidity("");
if (!el.checkValidity()) nativeErrors[el.name as keyof Errors] = el.validationMessage;
}
const snap = actor.getSnapshot();
actor.send(snap.matches({ submission: "failed" }) ? { type: "RETRY", formData: new FormData(form) } : { type: "SUBMIT", formData: new FormData(form), nativeErrors });
});
actor.subscribe((snap) => {
const errors = snap.context.errors;
for (const el of form.querySelectorAll<HTMLInputElement>("[name]")) el.setCustomValidity(errors[el.name as keyof Errors] ?? "");
form.setAttribute("aria-busy", String(snap.matches({ submission: "sending" }) || snap.matches({ username: "checking" })));
if (snap.matches({ submission: "editing" }) && snap.changed && Object.values(errors).some(Boolean)) form.reportValidity();
});
XState Option Reference
| Feature | API | Used for | Notes |
|---|---|---|---|
| Typed setup | setup({ types, actors, guards, delays }) |
Declaring everything the machine uses | Enables full type inference |
| Parallel regions | type: "parallel" |
Username check alongside submission | Independent lifecycles in one machine |
| Delayed transition | after: { debounce: … } |
Debounce without timers | Re-entering the state restarts the delay |
| Invoked promise actor | invoke: { src, input, onDone, onError } |
Fetches | Stopped (and signal aborted) on exit |
| Guards | guard: "canRetry" |
Validity, retry limits | Pure predicates on context and event |
assign |
action | Updating context | Only way context changes |
snapshot.matches({...}) |
method | Deriving UI | Check region states |
Verification Steps
import { describe, it, expect, vi } from "vitest";
import { createActor, fromPromise } from "xstate";
import { formMachine } from "./signup-machine";
describe("signup machine", () => {
it("debounces the username check", async () => {
vi.useFakeTimers();
const check = vi.fn(async () => true);
const actor = createActor(formMachine.provide({ actors: { checkUsername: fromPromise(check) } })).start();
actor.send({ type: "USERNAME.CHANGE", value: "ad" });
actor.send({ type: "USERNAME.CHANGE", value: "ada" });
await vi.advanceTimersByTimeAsync(399);
expect(check).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(check).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
});
machine.provide() swaps implementations in tests without touching the machine definition, which is how both actors are replaced with fakes.
Edge Cases and Failure Modes
Passing FormData through events. FormData is not serialisable, so it breaks XState’s inspector and persisted snapshots. For persistence, convert to a plain object before sending, and rebuild FormData inside the actor.
onError swallowing bugs. Treating every availability error as “unknown” is right for network failures but hides programming errors. Log event.error in development before transitioning.
Server errors for the username versus the async check. A 422 on submit can say “taken” even when the async check said “available” moments earlier. That is expected — the check is a hint — and the submission region simply replaces the context error.
Calling reportValidity() on every snapshot. Subscriptions fire often; calling reportValidity() each time steals focus while the user types. Only call it on entering editing with errors, as the example guards with snap.changed and the state check.
Binding the Actor in React
In a React component, useActor from @xstate/react runs the machine for the component’s lifetime and re-renders on every snapshot; useSelector narrows re-renders to the slice you read. The binding keeps the same division of labour as the DOM version: the component reads snapshot.context.errors and snapshot.matches(...), renders messages with aria-describedby, and mirrors errors into setCustomValidity() in an effect so reportValidity() can do the focusing.
import { useActor } from "@xstate/react";
export function SignupForm() {
const [snap, send] = useActor(formMachine);
const sending = snap.matches({ submission: "sending" });
const checking = snap.matches({ username: "checking" });
return (
<form noValidate aria-busy={sending} onSubmit={(e) => { e.preventDefault(); send(toSubmitEvent(e.currentTarget)); }}>
<label htmlFor="username">Username</label>
<input id="username" name="username" onInput={(e) => send({ type: "USERNAME.CHANGE", value: e.currentTarget.value })}
aria-describedby="username-status username-err" aria-invalid={Boolean(snap.context.errors.username)} />
<p id="username-status" role="status">{checking ? "Checking availability…" : snap.matches({ username: "available" }) ? "Available." : ""}</p>
{snap.context.errors.username && <p id="username-err" className="field-error">{snap.context.errors.username}</p>}
<button type="submit" aria-disabled={sending}>{sending ? "Creating…" : "Create account"}</button>
</form>
);
}
Visualising and Reviewing the Machine
One underrated benefit of XState is that the machine definition is data a tool can draw. The Stately inspector, or the VS Code extension, renders the machine above as a diagram with its parallel regions, delays and guards — which is exactly what a product manager or designer needs to review “what happens if the user edits the username while a submission is running?” without reading TypeScript. Commit an exported diagram alongside significant changes, and treat unexpected shapes (a state with no way out, an event handled nowhere) as review comments in their own right. The same review habit applies to hand-written reducers, but with XState the picture is generated from the code and therefore always current.
Frequently Asked Questions
How do I debounce validation in XState?
Make debouncing a state with a delayed transition, such as after: { debounce: … }, and re-enter it on every change event. Re-entering restarts the timer, so the transition to checking only fires after the user pauses.
How does XState cancel stale async validation?
Async work runs in an invoked actor. When the state that invoked it is exited — for example because the user typed again — XState stops the actor and aborts its signal, so a late response is never delivered.
Does XState replace the Constraint Validation API?
No. The machine orchestrates when validation runs and holds error context; the DOM binding still reads native validity with checkValidity and delivers errors with setCustomValidity and reportValidity.
How do I test an XState form machine?
Create an actor from the machine with provide() replacing network actors with fakes, send events, advance fake timers for delays, and assert on snapshot.matches() and context.
Related Guides
- Form State Machines — why machines suit forms.
- Modelling Form Submission with a Finite State Machine — the dependency-free version.
- Throttling vs Debouncing Server Validation — choosing timing for the username check.
- Username Validation Rules and Reserved Names — the rules behind the field.