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.

Username region of the XState machine The username region moves from idle to debouncing on each edit, to checking after 400 milliseconds, and to available or taken; any edit returns it to debouncing and cancels an in-flight check. idle debouncing checking available taken USERNAME.CHANGE after 400 ms done: true done: false USERNAME.CHANGE USERNAME.CHANGE
Debounce is a state with a delayed transition, and the availability request is an invoked actor that XState cancels whenever the region leaves checking.

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();
});
How the DOM and the actor talk DOM input and submit events become machine events carrying native validity results; the actor's snapshot flows back to the DOM as custom validity, aria-busy and reportValidity calls. DOM events input, submit Machine events USERNAME.CHANGE, SUBMIT Actor parallel regions, invoked actors Snapshot context.errors, matches() DOM output setCustomValidity, reportValidity
The actor never touches the DOM and the DOM never decides what happens next; each side does only its own job.

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>
  );
}
Vanilla subscription versus useActor Two columns comparing binding an XState actor to the DOM with a manual subscription against binding it in React with useActor. createActor + subscribe • works in any page, no framework • you update the DOM in the callback ✓ smallest footprint useActor / useSelector • React re-renders from the snapshot • lifecycle tied to the component ✓ declarative rendering of errors and status
Both bindings keep the machine free of UI code; the difference is only who renders the snapshot.

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.

← Back to Form State Machines