What you'll learn
- Quick answer
- What a State Machine Actually Is
- The Transition Table: Where the Logic Actually Lives
- Building It: A Real Order-Status State Machine
- Same Idea, Different Domain: UI State
- Backend Workflows: State Machines at Larger Scale
- Common Pitfalls, and When You Need More Than a Flat Machine
- When to Use This, and When a Boolean Is Fine
- FAQ
Quick Answer
A state machine models a system as a fixed set of states plus explicit rules for which transitions between them are legal, rejecting everything else. Instead of scattering boolean flags and if-chains through your code to track “is it shipped yet, can it still be refunded,” you define the states and legal transitions once, and every part of the app checks against the same source of truth. This is the backbone of order-processing systems, UI wizards, and workflow engines.
What a State Machine Actually Is
A state machine (formally, a finite state machine, or FSM) is a model where a system can be in exactly one of a fixed number of states at any moment, and moves between them only through explicitly allowed transitions. Everything else is illegal by default. That last part is the whole point: a state machine doesn't just describe what a system can do, it describes what it can't.
Most code that manages a status field doesn't do this. It uses a string or an enum column called status, and then scatters the rules about what can change it all over the codebase — a check here before shipping, a check there before refunding, maybe a comment reminding future you not to let a cancelled order get marked as delivered. Every one of those checks is a hand-written, easy-to-forget copy of a rule that a state machine would enforce in exactly one place.
The vocabulary is small and worth fixing before going further:
- State — one specific condition the system can be in, e.g.
pending,paid,shipped. - Event (or transition trigger) — something that happens and asks the machine to move, e.g. "payment received."
- Transition — the (current state, event) to next state rule.
- Guard — an extra condition that must hold for a transition to be allowed, beyond just being in the right state.
- Action — a side effect that runs when a transition happens, like sending an email.
Once states and transitions are written down as data — not as scattered if statements — you can do things ad hoc status tracking can't: enumerate every legal transition for documentation, generate a diagram straight from the code, and reject an illegal transition with one central check instead of hoping every code path remembered to guard itself.
The Transition Table: Where the Logic Actually Lives
The cleanest way to implement a state machine is a transition table: a mapping from "current state" to "the set of states you're allowed to move to from here." This is deliberately boring — a dictionary of sets, or a small table in a database — because boring means every future transition rule has exactly one place to be added, reviewed, and tested.
TRANSITIONS = {
"pending": {"paid", "cancelled"},
"paid": {"shipped", "refunded"},
"shipped": {"delivered", "returned"},
"delivered": set(), # terminal
"cancelled": set(), # terminal
"refunded": set(), # terminal
"returned": set(), # terminal
}
Reading this table answers questions that are genuinely hard to answer by grepping a codebase full of scattered status checks: which states are terminal (empty transition sets — nothing can happen to a delivered order), which states can reach refunded (only paid, not pending — you can't refund money you never took), and how many hops it takes to get from pending to delivered.
A transition function then does exactly one job: look up whether the requested move is in the allowed set for the current state, and either perform it or reject it. It does not know or care why the transition was requested — that's the caller's job. Keeping the state machine ignorant of business reasons ("why is this order being cancelled?") is what keeps it reusable.
Building It: A Real Order-Status State Machine
Here's a complete, runnable implementation in Python — an order-status flow with an explicit transition table and a hard rejection for anything not in it. This is the exact code used to produce the output below; nothing here is hypothetical.
class InvalidTransition(Exception):
pass
class OrderStateMachine:
TRANSITIONS = {
"pending": {"paid", "cancelled"},
"paid": {"shipped", "refunded"},
"shipped": {"delivered", "returned"},
"delivered": set(),
"cancelled": set(),
"refunded": set(),
"returned": set(),
}
def __init__(self, order_id):
self.order_id = order_id
self.state = "pending"
self.history = [("pending", None)]
def transition(self, event):
allowed = self.TRANSITIONS.get(self.state, set())
if event not in allowed:
raise InvalidTransition(
f"Order {self.order_id}: cannot go from '{self.state}' to "
f"'{event}' (allowed from here: {sorted(allowed) or 'none - terminal state'})"
)
old = self.state
self.state = event
self.history.append((event, old))
return self.state
Running a valid sequence — pay, ship, deliver — then attempting an illegal transition on the now-terminal order, produces this actual console output:
Start state: pending
Transitioned -> paid
Transitioned -> shipped
Transitioned -> delivered
History: [('pending', None), ('paid', 'pending'), ('shipped', 'paid'), ('delivered', 'shipped')]
Now attempting an invalid transition (delivered -> paid):
Rejected as expected: Order ORD-1001: cannot go from 'delivered' to 'paid' (allowed from here: none - terminal state)
Second scenario: cancel a pending order, then try to ship it
order2 start: pending
order2 state after cancel: cancelled
Rejected as expected: Order ORD-1002: cannot go from 'cancelled' to 'shipped' (allowed from here: none - terminal state)
Notice what the machine refuses to do even though nothing in Python's type system would stop you from calling order.transition("paid") on a delivered order: it raises a real exception, with the current state and the full list of legal next states baked into the message, so whoever's debugging this in production doesn't have to go read the transition table to understand why the call failed.
Same Idea, Different Domain: UI State
The order example is a backend workflow, but the exact same discipline fixes one of the most common sources of frontend bugs: UI components with multiple booleans that can end up in impossible combinations. A data-fetching component tracked with isLoading, isError, and data as three separate values can technically end up with isLoading = true and isError = true at the same time — a state that makes no sense but that nothing in the code prevents.
Model it as a state machine instead — idle, loading, success, error — and that combination becomes structurally impossible, because the component can only ever be in one of the four states, never two. The render logic becomes a plain switch over one variable instead of a nest of boolean conditions, and every path the UI can take is enumerable just by reading the state list. This is the idea behind mature frontend state machine libraries like XState, and it's why "loading spinner shows up alongside an error message" bugs are almost always a sign that state was modeled as independent flags instead of as one machine.
Multi-step forms and wizards benefit the same way: each step is a state, "next" and "back" are events, and a step that requires a previous step's data can guard the transition into it — refusing to let the user reach "payment" before "shipping address" is filled in, for instance. You get this for free once the flow is a machine instead of a page counter with a pile of validation checks bolted on.
Backend Workflows: State Machines at Larger Scale
The same pattern scales up to systems that coordinate real infrastructure, not just a UI. A CI/CD pipeline moves a build through queued to running to passed/failed to deployed, and a deploy that's already failed should never silently become deployed. Approval workflows (a purchase request going submitted to manager-review to approved/rejected to paid) have the same shape: specific roles are allowed to trigger specific transitions, and the audit trail is just the transition history.
At this scale, teams usually reach for either a library built into their web framework (Django's django-fsm, Rails' aasm) or a managed orchestration service (AWS Step Functions, Temporal) rather than hand-rolling a transition table, because the framework also handles persistence — saving the current state to a database row — and often visualizes the whole machine as a diagram automatically. The core idea is unchanged from the order example: one table of legal transitions, one function that checks against it, and a hard rejection for anything else.
Persistence is the one wrinkle that a language-level state machine object doesn't solve for you: if the state lives in a database column, the transition and the database write have to happen atomically, or you risk two concurrent requests both reading paid and both successfully transitioning to different next states. In practice this means the transition check and the update should happen inside the same database transaction, often with a row-level lock or an optimistic version check, not as two separate steps that a scheduler could interleave.
Common Pitfalls, and When You Need More Than a Flat Machine
The most common mistake isn't choosing the wrong state machine library — it's not building one at all, and instead adding "just one more boolean" every few months until a status is really being tracked by four flags that can contradict each other. If you find yourself writing a comment like // isPaid should only be true if isCancelled is false, that comment is describing a transition rule your code isn't actually enforcing anywhere.
The second common mistake is the opposite: building a state machine and then leaving a back door around it, usually a direct database write ("just set status to shipped manually for this one order") that skips the transition check entirely. A state machine is only as strong as the fact that it's the only way to change the state — the moment there's a second path in, the guarantees are gone.
The third is state explosion: a flat list of named states stops being manageable once you have dozens of them with overlapping sub-behavior, e.g. "shipped-domestic" and "shipped-international" that share most transitions but differ in one guard. That's the point where flat FSMs are usually upgraded to statecharts (hierarchical/nested states, popularized by David Harel's 1987 paper and implemented by libraries like XState) — states can contain sub-states, and a transition defined on a parent state is inherited by all its children, which avoids repeating the same rule under every leaf state.
When to Use This, and When a Boolean Is Fine
Not everything needs a formal state machine. A single boolean like isPublished on a blog post, with no other field depending on it, is already a (trivial) two-state machine and doesn't need a library or a transition table — the two states and the one transition are obvious from the field's name.
Reach for an explicit state machine once any of these are true: there are more than three or four states, more than one field is used together to represent "status," a transition needs to be rejected under certain conditions, or you've been bitten by a status field ending up in a combination nobody intended. The investment is small — the order example above is under 30 lines — and the payoff is that "can this ever happen?" becomes a question you can answer by reading one table instead of auditing every code path that touches the status field.
