State Machines in Practice: Advantages, Disadvantages, and a TypeScript Example

State Machines in Practice: Advantages, Disadvantages, and a TypeScript Example

~ 6 min read

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:

  • idle
  • idleWithDraft
  • idleWithInvalidDraft
  • submitting
  • submittingWithRetry
  • errorRecoverable
  • errorFatal

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:

  • idle
  • submitting
  • success
  • error

Events:

  • SUBMIT
  • RESOLVE
  • REJECT
  • RESET

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.

all posts →