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.
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.
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.
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.
Related Guides
- Form State Machines — concepts and trade-offs.
- XState Form Validation Machine — the library-based version.
- Preventing Double Form Submission — the non-machine techniques for comparison.
- Handling 422 Unprocessable Content Responses — the responses the machine consumes.