Form State Machines
Most form bugs are not wrong rules; they are impossible states. A submit button that is enabled while a submission is in flight. A “Checking availability…” spinner that never stops because the response arrived after the user cleared the field. An error message shown and a success tick shown at the same time. A form that reports “saved” while still displaying the server’s validation errors from the previous attempt. Each of these happens because the form’s state is spread across half a dozen booleans — isSubmitting, isValid, hasErrors, isChecking, touched, submitted — whose combinations include many that should never exist. Modelling the form as a finite state machine replaces those booleans with a single named state and an explicit list of transitions, so impossible combinations cannot be represented at all. This topic shows how to do that with a small hand-written reducer and with XState, and how to keep the machine driving the site’s canonical novalidate plus reportValidity() pattern from the Constraint Validation API rather than replacing it.
The pain point is concrete: race conditions and inconsistent UI that are almost impossible to reproduce in testing because they depend on timing. A state machine makes every timing a named event with a defined outcome.
Prerequisites for State-Machine Forms
| Requirement | Minimum version | Why it is needed |
|---|---|---|
| TypeScript | 5.0+ | Discriminated unions for states and events |
| A reducer or state library | Hand-written, or XState 5 | Enforcing allowed transitions |
AbortController |
All evergreen browsers | Cancelling async work when leaving a state |
| Framework binding (optional) | useReducer, @xstate/react, @xstate/vue, signals |
Rendering from state |
| Constraint Validation API | All browsers | Delivering errors, focus and announcements |
| Test runner | Vitest or Jest | Exhaustive transition tests |
State Machine API Reference
| Concept | Type | Purpose | Example |
|---|---|---|---|
| State | string literal union | Where the form is now | "editing" | "submitting" |
| Context | object | Data carried across states | { errors, attempt, serverErrors } |
| Event | discriminated union | Something that happened | { type: "SUBMIT" }, { type: "RESPONSE"; status: 422 } |
| Transition | (state, event) => state |
Allowed moves only | validating + VALID → submitting |
| Guard | predicate | Conditional transition | canRetry: attempt < 3 |
| Entry action | side effect | Runs on entering a state | Start fetch in submitting |
| Exit action | side effect | Runs on leaving a state | Abort fetch when leaving submitting |
| Derived UI | pure function of state | What to render | button disabled = state === "submitting" |
Step-by-Step Implementation
1. Name the states and events as types
type FieldErrors = Record<string, string>;
type FormState =
| { status: "editing"; errors: FieldErrors }
| { status: "validating"; errors: FieldErrors }
| { status: "submitting"; attempt: number }
| { status: "succeeded" }
| { status: "failed"; attempt: number; message: string };
type FormEvent =
| { type: "SUBMIT" }
| { type: "VALID" }
| { type: "INVALID"; errors: FieldErrors }
| { type: "SERVER_ERRORS"; errors: FieldErrors }
| { type: "SUCCESS" }
| { type: "FAILURE"; message: string }
| { type: "RETRY" }
| { type: "EDIT" };
Carrying data inside the state variant — errors only exist in editing, the attempt count only in submitting and failed — is what removes impossible combinations. There is no way to write “succeeded with errors”.
2. Write the transition function
export function transition(state: FormState, event: FormEvent): FormState {
switch (state.status) {
case "editing":
if (event.type === "SUBMIT") return { status: "validating", errors: state.errors };
if (event.type === "EDIT") return state; // edits never leave editing
return state;
case "validating":
if (event.type === "VALID") return { status: "submitting", attempt: 1 };
if (event.type === "INVALID") return { status: "editing", errors: event.errors };
return state;
case "submitting":
if (event.type === "SUCCESS") return { status: "succeeded" };
if (event.type === "SERVER_ERRORS") return { status: "editing", errors: event.errors };
if (event.type === "FAILURE") return { status: "failed", attempt: state.attempt, message: event.message };
return state; // SUBMIT ignored: no double submission
case "failed":
if (event.type === "RETRY" && state.attempt < 3) return { status: "submitting", attempt: state.attempt + 1 };
if (event.type === "EDIT") return { status: "editing", errors: {} };
return state;
case "succeeded":
return state; // terminal
}
}
The most valuable line is the last return state in submitting: a second SUBMIT event while a request is in flight is simply ignored. Double submission is not “prevented” by disabling a button; it is impossible by construction. The detailed walkthrough is modelling form submission with a finite state machine.
3. Run side effects on state entry, not in event handlers
let state: FormState = { status: "editing", errors: {} };
let inflight: AbortController | undefined;
const form = document.querySelector<HTMLFormElement>("#profile")!;
function send(event: FormEvent): void {
const prev = state;
state = transition(state, event);
if (state === prev) return;
if (prev.status === "submitting") inflight?.abort(); // exit action
render(state);
onEnter(state); // entry actions
}
function onEnter(s: FormState): void {
if (s.status === "validating") {
const ok = form.checkValidity() && runCustomRules(form);
send(ok ? { type: "VALID" } : { type: "INVALID", errors: collectErrors(form) });
}
if (s.status === "submitting") {
inflight = new AbortController();
fetch(form.action, { method: "POST", body: new FormData(form), signal: inflight.signal })
.then(async (res) => {
if (res.ok) return send({ type: "SUCCESS" });
if (res.status === 422) return send({ type: "SERVER_ERRORS", errors: (await res.json()).errors });
send({ type: "FAILURE", message: "We couldn't save your changes." });
})
.catch((e) => e.name !== "AbortError" && send({ type: "FAILURE", message: "Check your connection and try again." }));
}
}
form.addEventListener("submit", (e) => { e.preventDefault(); send({ type: "SUBMIT" }); });
form.addEventListener("input", () => send({ type: "EDIT" }));
4. Derive the UI from the state
function render(s: FormState): void {
const button = form.querySelector<HTMLButtonElement>("[type=submit]")!;
form.setAttribute("aria-busy", String(s.status === "submitting" || s.status === "validating"));
button.setAttribute("aria-disabled", String(s.status === "submitting"));
button.textContent = s.status === "submitting" ? "Saving…" : s.status === "failed" ? "Try again" : "Save";
if (s.status === "editing") {
for (const el of form.querySelectorAll<HTMLInputElement>("[name]")) el.setCustomValidity(s.errors[el.name] ?? "");
if (Object.keys(s.errors).length) form.reportValidity(); // canonical focus + announcement
}
document.querySelector("#form-status")!.textContent =
s.status === "succeeded" ? "Saved." : s.status === "failed" ? s.message : "";
}
Every visible element is a pure function of the current state. There is no code path that can show “Saving…” next to a validation error, because those belong to different states.
State Management and Edge Cases
State machines shine exactly where forms usually break:
- Stale async responses. Leaving
submittingruns its exit action, which aborts the request. A late response for an aborted request never reachessend, so it cannot overwrite newer state. Field-level async checks get the same treatment in their own small machines, as in cancelling stale requests with AbortController. - Retries with limits. The guard
attempt < 3lives in the transition, not in UI code, so it cannot be bypassed by a keyboard shortcut or a second button. - Editing during submission. The machine decides: in this design,
EDITis ignored whilesubmitting. A different product might transition to adirtyWhileSubmittingstate and resubmit — the point is that the choice is explicit. - Per-field state. Each field has its own tiny lifecycle — pristine, touched, validating, valid, invalid — best kept in a parallel region or a separate machine per field, described in tracking dirty, touched and pristine state.
Accessibility Compliance Driven by State
A state machine makes accessible behaviour easier to get right because each accessibility requirement maps to a state. 4.1.3 Status Messages: entering succeeded or failed writes one message to a polite status region; entering editing with errors calls reportValidity(), which focuses and announces. 3.3.1 Error Identification: errors exist only in the editing state and are always rendered through setCustomValidity() plus a described-by message. 2.4.3 Focus Order: focus moves only on state entry — to the first invalid field on entering editing with errors, or to a confirmation heading on entering succeeded — never as a side effect scattered across handlers. And aria-busy is a direct projection of the submitting state, so it can never be left true by a forgotten finally block.
Common Gotchas and Debugging
Side effects inside the transition function. Starting a fetch inside transition makes it impure and untestable, and a transition that is computed twice (in React strict mode, for example) fires two requests.
// Before: effect inside the reducer
case "validating": if (event.type === "VALID") { fetch(url); return { status: "submitting", attempt: 1 }; }
// After: the reducer only computes; effects run on entering "submitting"
case "validating": if (event.type === "VALID") return { status: "submitting", attempt: 1 };
Deriving state from the DOM. Reading button.disabled to decide whether a submission is in progress reintroduces the boolean problem. Read the machine’s state.
Too many states. A machine with forty states for one form is a sign that per-field concerns have leaked into the form-level machine. Split fields into their own machines or parallel regions.
Ignoring unknown events silently in development. A typo in an event name does nothing, which is hard to debug. Log ignored events in development builds.
Testing Machines Exhaustively
Because the transition function is pure, you can test every state against every event — a table of a few dozen cases — and prove properties like “a SUBMIT during submitting never starts a second request”. This is the single biggest practical advantage over flag-based forms, whose behaviour depends on the order in which handlers happen to run.
import { describe, it, expect } from "vitest";
import { transition } from "./form-machine";
describe("form machine", () => {
it("ignores SUBMIT while submitting", () => {
const s = { status: "submitting", attempt: 1 } as const;
expect(transition(s, { type: "SUBMIT" })).toBe(s);
});
it("returns to editing with server errors", () => {
expect(transition({ status: "submitting", attempt: 1 }, { type: "SERVER_ERRORS", errors: { email: "Taken" } }))
.toEqual({ status: "editing", errors: { email: "Taken" } });
});
it("stops retrying after three attempts", () => {
const s = { status: "failed", attempt: 3, message: "x" } as const;
expect(transition(s, { type: "RETRY" })).toBe(s);
});
});
Binding a Form Machine to React, Vue and Signals
The reducer above is framework-neutral, which makes binding it to a component a thin layer. In React, useReducer(transition, initial) gives you the state and a dispatch that plays the role of send; run entry actions in a useEffect keyed on state.status, and return the AbortController’s abort from that effect as the cleanup, which is exactly an exit action. In Vue, keep the state in a shallowRef, replace it in send, and use a watch on state.value.status for entry actions with the watcher’s onCleanup for exit actions. With signals (Solid, Preact, Angular), a single signal holds the state and an effect keyed on the status runs side effects with cleanup. In every case the rule is the same: the component renders from the state and translates DOM events into machine events; it never keeps parallel useState booleans that could disagree with the machine.
import { useEffect, useReducer } from "react";
export function useFormMachine(form: React.RefObject<HTMLFormElement>) {
const [state, send] = useReducer(transition, { status: "editing", errors: {} } as FormState);
useEffect(() => {
if (state.status !== "submitting") return;
const ctrl = new AbortController();
submit(form.current!, ctrl.signal).then(send); // resolves to a machine event
return () => ctrl.abort(); // exit action: leaving "submitting" aborts
}, [state.status]);
return [state, send] as const;
}
React’s strict mode deliberately runs effects twice in development; with the cleanup aborting the first request, the second run starts cleanly, which is a useful check that your exit actions are correct.
Modelling Multi-Step Forms as Machines
Multi-step forms are state machines whether or not you write them as one: each step is a state, “Next” and “Back” are events, and validation guards the forward transitions. Making that explicit fixes the usual bugs — skipping a step through the browser’s back button, submitting from an intermediate step, losing a step’s data when going back. Each step state carries the data collected so far in context; the NEXT transition is guarded by that step’s slice of the schema; BACK is always allowed and never validates; and only the final review state can transition to submitting. The step-by-step validation rules themselves are covered in validating multi-step forms per step; the machine is what guarantees they cannot be bypassed.
Persisting and Restoring Machine State
Because the whole form is described by one serialisable value, persisting it is trivial — and useful. Save the state (minus secrets) to sessionStorage on every transition, and a user who reloads mid-form, or whose tab is discarded by a mobile browser, returns to exactly the same step with the same errors. Restore carefully, though: transient states such as validating and submitting describe work that no longer exists after a reload, so map them back to editing on restore, and let a restored failed state show its message with a retry button rather than retrying automatically. Treat restored data as untrusted input — validate it through the same schema before use — because storage can be edited or corrupted like any other client data.
Designing the State List With the Team
The states of a form machine are a product decision as much as a technical one, and writing them down before coding surfaces questions that would otherwise be answered by accident. What happens if the user edits a field while a submission is in flight? Can a failed submission be retried automatically, and how many times? Is “saved” a terminal state or does the form return to editing? Sketch the states and transitions on one page, review it with design and product, and then encode exactly that list — the diagram at the top of this page is the kind of artefact that belongs in the pull request.
Debugging Form Machines
Explicit states make debugging dramatically easier, provided you log them. In development builds, log every transition as status + event → next status, and log ignored events separately; the log reads like a story of what the user did and why the form responded as it did. Attach the last few transitions to error reports in production (without field values), so a support ticket about “the button stopped working” comes with the exact sequence that led there — often a SUBMIT ignored during submitting, which is correct, or an event nobody expected in a state that has no transition for it, which is a design gap.
Hand-Written Reducer or XState?
For a single form with five or six states, a hand-written reducer like the one above is small, dependency-free and easy to read. XState earns its place when machines grow: parallel regions for independent fields, delayed transitions (debounce as a state), invoked actors for async work that is automatically cancelled when a state exits, and visual tooling that renders the machine as a diagram for reviewers. The migration path is gentle because the concepts are identical — states, events, guards, entry and exit actions — so a reducer can be ported when it outgrows itself. The XState version of this form is built in XState form validation machine.
Browser Compatibility Matrix
| Feature | Chromium | Firefox | Safari | Notes |
|---|---|---|---|---|
AbortController / signal on fetch |
66+ | 57+ | 12.1+ | Exit actions cancel requests |
AbortSignal.timeout |
103+ | 100+ | 16+ | Bounded submitting state |
structuredClone for context snapshots |
98+ | 94+ | 15.4+ | Debugging and time-travel |
aria-busy support in screen readers |
Varies | Varies | Varies | Pair with a status message; do not rely on it alone |
Frequently Asked Questions
Why use a state machine for form validation?
To make impossible states unrepresentable. Instead of several booleans whose combinations include nonsense like "submitting with errors", the form has one named state and an explicit list of allowed transitions, which removes whole classes of race conditions.
Does a state machine replace the Constraint Validation API?
No. The machine decides when validation runs and what happens next; the Constraint Validation API still evaluates native constraints and delivers errors through setCustomValidity() and reportValidity().
How does a state machine prevent double submission?
The submitting state simply ignores further SUBMIT events, so a second click or Enter press has no effect. The protection comes from the transition table, not from remembering to disable a button.
Do I need XState to use state machines for forms?
No. A typed reducer with a switch over states is enough for most forms. XState helps when you need parallel regions, delays, invoked async actors or visual tooling.
Related Guides
- Modelling Form Submission with a Finite State Machine — the reducer in depth.
- XState Form Validation Machine — the same form with XState 5.
- Tracking Dirty, Touched and Pristine State — per-field lifecycle.
- Asynchronous Server Checks — async validation that a machine makes deterministic.
← Back to Advanced JavaScript Validation Logic & Patterns