Modelling Form Submission with a Finite State Machine

How do you rewrite a submit handler that has grown isLoading, isError, errorMessage, serverErrors and hasSubmitted flags — and a bug that appears only when the network is slow — into something whose behaviour you can read and test in one place? This recipe converts the submission flow into a typed finite state machine in plain TypeScript: a discriminated union of states that carry exactly their own data, a pure transition table, effects that run only when a state is entered or left, and a render function that reads nothing but the current state. Validation still flows through checkValidity(), setCustomValidity() and reportValidity() from the Constraint Validation API; the machine decides when those calls happen.

When to Model Submission as a State Machine

The refactor pays for itself when the submit flow has more than two outcomes or any asynchronous step. Reach for it when:

  • Bugs depend on timing — double submissions, spinners that never stop, errors from an old attempt appearing after a new one.
  • The flow has several outcomes — success, field errors, form-level failure, retry, conflict — each with different UI.
  • Several people maintain the form, and a single transition table is easier to review than handlers spread across components.

For a form that posts natively and lets the server re-render the page, a machine adds little; the browser already serialises the flow. The concepts and trade-offs are introduced in the form state machines topic; this page is the concrete refactor.

Before and after the refactor Two columns contrasting a submit handler built from independent flags with the same flow expressed as a finite state machine. Flag-based handler • five booleans and two message variables ✗ finally blocks must reset every flag ✗ late responses overwrite newer state ✗ behaviour spread across several handlers State machine • one status with data per state ✓ transitions in one pure function ✓ exit actions abort stale work ✓ UI derived from status alone
The machine version has fewer lines of UI logic because every rendering decision reads a single status value.

Minimal Working Submission Machine

// form-machine.ts — no DOM, no fetch: pure and testable
export type Errors = Readonly<Record<string, string>>;

export type State =
  | { readonly status: "idle"; readonly errors: Errors }
  | { readonly status: "checking" }
  | { readonly status: "sending"; readonly attempt: number }
  | { readonly status: "done" }
  | { readonly status: "error"; readonly attempt: number; readonly reason: "network" | "server" };

export type Event =
  | { readonly type: "submit" }
  | { readonly type: "checked"; readonly errors: Errors }
  | { readonly type: "response"; readonly status: number; readonly errors?: Errors }
  | { readonly type: "network-failed" }
  | { readonly type: "retry" }
  | { readonly type: "edited" };

export const initial: State = { status: "idle", errors: {} };
const MAX_ATTEMPTS = 3;

export function next(state: State, event: Event): State {
  switch (state.status) {
    case "idle":
      return event.type === "submit" ? { status: "checking" } : state;

    case "checking":
      if (event.type !== "checked") return state;
      return Object.keys(event.errors).length ? { status: "idle", errors: event.errors } : { status: "sending", attempt: 1 };

    case "sending":
      if (event.type === "network-failed") return { status: "error", attempt: state.attempt, reason: "network" };
      if (event.type !== "response") return state;          // "submit" while sending: ignored
      if (event.status >= 200 && event.status < 300) return { status: "done" };
      if (event.status === 422) return { status: "idle", errors: event.errors ?? {} };
      return { status: "error", attempt: state.attempt, reason: "server" };

    case "error":
      if (event.type === "retry" && state.attempt < MAX_ATTEMPTS) return { status: "sending", attempt: state.attempt + 1 };
      if (event.type === "edited") return { status: "idle", errors: {} };
      return state;

    case "done":
      return state;
  }
}
// form-controller.ts — wires the pure machine to the DOM
import { next, initial, type State, type Event } from "./form-machine";

export function controlForm(form: HTMLFormElement): void {
  let state: State = initial;
  let abort: AbortController | undefined;
  const status = form.querySelector<HTMLElement>("[role=status]")!;
  const button = form.querySelector<HTMLButtonElement>("[type=submit]")!;

  const send = (event: Event) => {
    const before = state;
    state = next(state, event);
    if (state === before) return;
    if (before.status === "sending") abort?.abort();          // exit "sending": cancel the request
    paint();
    enter();
  };

  const enter = () => {
    if (state.status === "checking") {
      // Validation is synchronous here: native constraints + custom rules via setCustomValidity.
      const errors: Record<string, string> = {};
      for (const el of form.querySelectorAll<HTMLInputElement>("[name]")) {
        el.setCustomValidity("");
        const custom = customRule(el);
        if (custom) el.setCustomValidity(custom);
        if (!el.checkValidity()) errors[el.name] = el.validationMessage;
      }
      send({ type: "checked", errors });
    }
    if (state.status === "sending") {
      abort = new AbortController();
      fetch(form.action, { method: "POST", body: new FormData(form), signal: abort.signal, headers: { accept: "application/json" } })
        .then(async (res) => send({ type: "response", status: res.status, errors: res.status === 422 ? (await res.json()).errors : undefined }))
        .catch((err) => { if (err.name !== "AbortError") send({ type: "network-failed" }); });
    }
  };

  const paint = () => {
    const busy = state.status === "checking" || state.status === "sending";
    form.setAttribute("aria-busy", String(busy));
    button.setAttribute("aria-disabled", String(busy));
    button.textContent = state.status === "error" ? "Try again" : busy ? "Saving…" : "Save";
    status.textContent =
      state.status === "done" ? "Your changes were saved." :
      state.status === "error" ? (state.reason === "network" ? "You appear to be offline. Try again when connected." : "We couldn't save your changes.") : "";
    if (state.status === "idle") {
      for (const el of form.querySelectorAll<HTMLInputElement>("[name]")) el.setCustomValidity(state.errors[el.name] ?? "");
      if (Object.keys(state.errors).length) form.reportValidity();
    }
  };

  form.addEventListener("submit", (e) => {
    e.preventDefault();
    send(state.status === "error" ? { type: "retry" } : { type: "submit" });
  });
  form.addEventListener("input", () => send({ type: "edited" }));
}

Two files, two responsibilities. form-machine.ts answers “what happens next?” and nothing else, so it can be tested with plain objects. form-controller.ts answers “what does that mean for the page?” and contains every side effect, each attached to entering or leaving a specific state.

A slow response arriving after a retry The first request stalls, the user retries after a network failure, the machine aborts nothing stale because it already left sending, and only the second request's response is applied. User Machine Network submit POST attempt 1 network-failed (timeout) error, attempt 1 → "Try again" retry POST attempt 2 response 200 → done
Responses are only accepted in the sending state, and leaving that state aborts its request, so a late reply can never overwrite a newer outcome.

Machine Option Reference

Item Type Default Purpose
State union discriminated union five statuses Data lives only in the states that need it
Event union discriminated union six events Everything that can happen, named
next(state, event) pure function Returns the same object when an event is ignored
MAX_ATTEMPTS number 3 Retry guard inside the transition
Exit action on leaving sending abort fetch Stale responses cannot arrive
Entry actions on checking, sending validate, fetch The only place effects run
paint() render function Reads only state

Returning the same object for ignored events lets the controller skip rendering with a cheap identity check, and makes “was this event ignored?” trivially testable.

Verification Steps

import { describe, it, expect } from "vitest";
import { next, initial, type State, type Event } from "./form-machine";

const run = (events: Event[], from: State = initial) => events.reduce(next, from);

describe("submission machine", () => {
  it("ignores a second submit while sending", () => {
    const sending = run([{ type: "submit" }, { type: "checked", errors: {} }]);
    expect(next(sending, { type: "submit" })).toBe(sending);
  });
  it("goes back to idle with server field errors", () => {
    expect(run([{ type: "submit" }, { type: "checked", errors: {} }, { type: "response", status: 422, errors: { email: "Taken" } }]))
      .toEqual({ status: "idle", errors: { email: "Taken" } });
  });
  it("caps retries", () => {
    const failed: State = { status: "error", attempt: 3, reason: "network" };
    expect(next(failed, { type: "retry" })).toBe(failed);
  });
});

Edge Cases and Failure Modes

Events from the wrong state. A response event can only mean something in sending. If a bug dispatches it elsewhere, the machine ignores it — which is safe but can hide the bug. Log ignored events in development.

Asynchronous validation inside checking. If validation includes an async rule, checking must itself be abortable: store an AbortController for it too, and abort it on exit, exactly like sending. Otherwise an edit during a slow check could be followed by a stale checked event.

Rendering in the middle of a transition. Entry actions can dispatch events synchronously (as checking does). The controller paints after each transition, so the page may briefly render checking before sending. That is fine and even useful for screen readers via aria-busy, but keep paint() cheap.

Terminal done state. After success, further events are ignored. If the form should be reusable (a “send another message” flow), add an explicit reset event from done rather than mutating the state from outside.

Migrating an Existing Handler Incrementally

You do not have to rewrite a complex form in one go. Start by writing down the statuses the existing flags actually represent — usually four or five — and introduce the State type alongside the old flags, computing it from them in a single function. Then move one piece of rendering at a time to read from the derived state instead of the flags. Once every render reads state, flip the direction: make the machine the source and derive the old flags from it for any code you have not migrated. Finally delete the flags. Each step is a small, reviewable change, and the tests for next can be written at the very first step, documenting the intended behaviour before any code depends on it. When the machine later needs debounced field checks or parallel regions, the same types carry over to XState.

Incremental migration from flags to a machine Four shippable steps: derive a status from existing flags, move rendering to the status, make the machine the source of truth, then delete the flags. Derive status from existing flags Render from status one view at a time Machine is source flags derived from it Delete flags tests stay green
Each step is small and reversible; the tests for the transition function can be written before any code depends on it.

Frequently Asked Questions

How do I model a form submission as a finite state machine?

List the statuses the form can be in — for example idle, checking, sending, done and error — define the events that can happen, and write a pure function that returns the next state for each state and event pair. Run side effects only when entering or leaving states.

How does the machine stop a late response from overwriting newer state?

Responses are only accepted in the sending state, and leaving sending aborts its request. A response for an aborted request never reaches the machine, and one that arrives in another state is ignored.

Where do validation calls like reportValidity go?

In the controller: the checking state's entry action runs checkValidity and custom rules, and entering idle with errors applies setCustomValidity and calls reportValidity for focus and announcement.

How do I test a form state machine?

Test the pure transition function directly with sequences of events, asserting the resulting state. Because it has no DOM or network, the tests are fast and can cover every state and event.

← Back to Form State Machines