Why state machines show up everywhere
If you’ve ever shipped a UI with “loading”, “success”, and “error” states, or built a service that retries, backoffs, then gives up… you’ve already been designing a state machine, whether you called it that or not.
A finite state machine (FSM) is a model where:
- You have a finite set of states (e.g.
idle,submitting,success,error) - Your system moves between them via events (e.g.
SUBMIT,RESOLVE,REJECT,RETRY) - Only certain transitions are allowed (e.g. you can’t “resolve” if you never submitted)
The real value is not the diagram or the buzzword, it’s making “what can happen next?” explicit and enforceable.
The advantages
1) Predictability and fewer “impossible” bugs
When transitions are explicit, you stop having ghost states like “loading + error banner + submit button disabled but also clickable”. With an FSM, those combinations often can’t exist because the system can only be in one state at a time (or in a defined parallel state setup if you’re using a more advanced model).
This predictability makes behavior easier to reason about, especially under concurrency (rapid clicks, double submits, retries, cancellation).
2) Better debugging: timelines beat guesswork
State machines produce great logs:
- state A → (event X) → state B
- state B → (event Y) → state C
That’s a clean timeline that explains why you ended up somewhere, instead of reverse-engineering a handful of booleans.
3) More maintainable as complexity grows
A common path is:
- Start with
isLoading: boolean - Add
hasError: boolean - Add
errorMessage?: string - Add
isRefreshing: boolean - Add “retrying” logic
- Add cancellation
- Add “optimistic UI” cases
Soon you’re managing a truth table of boolean combinations. State machines let you represent complexity as states + transitions rather than flags + conditions.
4) Testability: transition tests are cheap and thorough
FSM testing is often just:
- given state S and event E, assert next state is S’
You can cover many scenarios without mocking the world. And because transitions are centralised, tests stay focused.
5) Great fit for human workflows
Anything that resembles a “process” benefits:
- Checkout flows
- Authentication / session lifecycles
- File uploads
- Payments
- Hardware/device protocols
- Back-end job pipelines
If someone can draw the flow on a whiteboard, there’s a good chance a state machine can represent it cleanly.
The disadvantages
1) Up-front modeling cost
State machines push you to name states and define transitions early. That’s a feature, until you’re in a fast-moving prototype phase and the workflow changes daily.
If the product/design is extremely fluid, an FSM can feel like “too much ceremony” at first.
2) State explosion is real
When you model every nuance as a separate state, you can get:
idleidleWithDraftidleWithInvalidDraftsubmittingsubmittingWithRetryerrorRecoverableerrorFatal- …
That’s not inherently wrong, but it can become unwieldy. Often the fix is to:
- keep the core workflow in states
- keep data (like error messages) as attached context
3) Harder to represent multiple independent concerns
Classic finite state machines are “one state at a time.” Real systems often have independent axes:
- network status (online/offline)
- auth status (anon/logged in)
- feature flags (enabled/disabled)
- UI mode (compact/expanded)
If these concerns truly evolve independently, you may prefer:
- parallel state machines, or
- multiple smaller machines, or
- an actor model/message bus approach
Trying to cram independent dimensions into one giant FSM can get ugly.
4) Tooling and team familiarity
Some teams love state charts and tools like XState; others have never used them and will resist the learning curve. A simple “hand-rolled FSM” can be a middle path, but you still need discipline to keep transitions centralised.
When a state machine is worth it
A simple rule of thumb:
- If you have more than ~3 meaningful states and events can arrive in surprising orders, a state machine often pays off.
- If you’re already juggling multiple booleans or writing lots of “if (loading && !error && …)” checks, an FSM can simplify the mental model.
- If correctness is important (payments, auth, data integrity), explicit transitions are a big win.
A TypeScript example: a submit flow FSM
Let’s model a typical “submit something” workflow:
States:
idlesubmittingsuccesserror
Events:
SUBMITRESOLVEREJECTRESET
We’ll implement:
- a typed
transition()function - an explicit transition table
- an exhaustive check so missing cases fail at compile time
The code
// A tiny, typed finite state machine for a submit flow.
// No libraries required.
type IdleState = { status: "idle" };
type SubmittingState = { status: "submitting" };
type SuccessState = { status: "success" };
type ErrorState = { status: "error"; message: string };
type State = IdleState | SubmittingState | SuccessState | ErrorState;
type SubmitEvent = { type: "SUBMIT" };
type ResolveEvent = { type: "RESOLVE" };
type RejectEvent = { type: "REJECT"; message: string };
type ResetEvent = { type: "RESET" };
type Event = SubmitEvent | ResolveEvent | RejectEvent | ResetEvent;
function assertNever(x: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}
export function transition(state: State, event: Event): State {
switch (state.status) {
case "idle": {
switch (event.type) {
case "SUBMIT":
return { status: "submitting" };
case "RESET":
return state; // no-op
case "RESOLVE":
case "REJECT":
// These events don't make sense from idle; ignore or throw based on your needs.
return state;
default:
return assertNever(event);
}
}
case "submitting": {
switch (event.type) {
case "RESOLVE":
return { status: "success" };
case "REJECT":
return { status: "error", message: event.message };
case "SUBMIT":
return state; // prevent double-submit by ignoring
case "RESET":
return { status: "idle" };
default:
return assertNever(event);
}
}
case "success": {
switch (event.type) {
case "RESET":
return { status: "idle" };
// In many apps, SUBMIT from success means "submit another"
case "SUBMIT":
return { status: "submitting" };
case "RESOLVE":
case "REJECT":
return state;
default:
return assertNever(event);
}
}
case "error": {
switch (event.type) {
case "RESET":
return { status: "idle" };
case "SUBMIT":
return { status: "submitting" }; // retry
case "RESOLVE":
case "REJECT":
return state;
default:
return assertNever(event);
}
}
default:
return assertNever(state);
}
}
Using it in an async action
let state: State = { status: "idle" };
async function submit(payload: unknown) {
state = transition(state, { type: "SUBMIT" });
try {
await fakeApiCall(payload);
state = transition(state, { type: "RESOLVE" });
} catch (err) {
const message =
err instanceof Error ? err.message : "Something went wrong";
state = transition(state, { type: "REJECT", message });
}
}
async function fakeApiCall(_: unknown) {
// demo: flip success/failure
await new Promise((r) => setTimeout(r, 250));
if (Math.random() < 0.5) throw new Error("Network error");
}
Notice what we didn’t need:
- isLoading
- hasError
- isSuccess
- multiple nested if checks to keep those flags consistent
Instead, the state machine enforces which transitions are possible and makes the flow obvious.
Practical tips to avoid the common pitfalls
- Keep the workflow in the status and keep data (messages, IDs, results) as state fields.
- Decide early what to do with invalid events: ignore, throw, or log. (Different systems want different behaviour.)
- Prefer small machines over one megamachine. Compose them if needed.
- If your machine grows beyond what’s pleasant in code, consider a library or a statechart tool, but only once the complexity justifies it.
Wrap-up
State machines are a way to make behaviour explicit: what states exist, what events can occur, and what happens next. They shine when systems become interactive, concurrent, or correctness-sensitive, and they can feel heavy when the workflow is still changing rapidly.
If you’re seeing boolean flag soup or “how did we even get into this state?” bugs, that’s often your cue: it’s probably time for a state machine.