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.

A form submission state machine A form moves from editing to validating on submit, then either back to editing with errors or on to submitting, which ends in success or returns to editing with server errors or a failure. editing validating submitting succeeded failed SUBMIT VALID INVALID 2xx 422 field errors network / 5xx RETRY
Five states replace a tangle of booleans; each transition is a named event, so "submitting while showing errors" simply has no representation.

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.

Event, transition, effect, render A DOM event is translated into a machine event, the transition function computes the next state, exit and entry actions run side effects, and the UI is re-rendered from the new state. DOM event submit, input, response Machine event SUBMIT, EDIT, SERVER_ERRORS transition() pure, exhaustive Exit / entry actions abort, fetch, validate render(state) setCustomValidity, aria-busy
The transition function is pure and testable; side effects happen only on state entry and exit; the UI never reads anything but the state.

State Management and Edge Cases

State machines shine exactly where forms usually break:

  • Stale async responses. Leaving submitting runs its exit action, which aborts the request. A late response for an aborted request never reaches send, 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 < 3 lives 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, EDIT is ignored while submitting. A different product might transition to a dirtyWhileSubmitting state 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.
Boolean flags versus a state machine Two columns comparing form state managed with independent boolean flags against a single explicit state machine. Independent booleans • isSubmitting, isValid, hasErrors, isChecking, submitted ✗ 32 combinations, most impossible ✗ races flip flags in the wrong order ✗ every handler must remember every flag One state machine • editing, validating, submitting, succeeded, failed ✓ only valid combinations exist ✓ late events ignored by design ✓ transitions testable in isolation
Five booleans allow thirty-two combinations, most of them nonsensical; five named states allow exactly five.

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.

Events in a multi-step machine A timeline of a user moving through a three-step form: NEXT events guarded by validation, a BACK event that never validates, a rejected NEXT, and the final submission from the review state. Step state details address details again review Events NEXT ok BACK NEXT rejected NEXT ok SUBMIT 0 s 10 s 20 s 30 s 40 s 50 s 60 s
Forward moves are validated, backward moves never are, and submission is only possible from the review state.

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.

Reducer versus XState for form machines A table comparing a hand-written reducer with XState across common form-machine needs. Hand-written reducer XState 5 Few states, one form ✓ simplest more setup Parallel field regions manual ✓ built in Delays and debounce timers by hand ✓ after Auto-cancelled async AbortController ✓ invoked actors Visual diagram ✗ No ✓ inspector
Start with a reducer; move to XState when you need parallel regions, delays and automatically cancelled actors.

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.

← Back to Advanced JavaScript Validation Logic & Patterns

Explore This Section