State applies when an object behaves differently depending on a mode it is in, and when it changes mode in response to what happens to it. The pattern gives each mode its own object, so the behaviour and the legal transitions live together.
Structurally it is Strategy. The difference is intent and it has real consequences: a strategy is chosen from outside and does not change itself; a state decides its own successor.
The problem: status flags and scattered checks
class Order {
cancel() {
if (this.status === 'shipped') throw new Error('cannot cancel a shipped order');
if (this.status === 'cancelled') throw new Error('already cancelled');
if (this.status === 'delivered') throw new Error('cannot cancel a delivered order');
this.status = 'cancelled';
}
ship() {
if (this.status !== 'paid') throw new Error('cannot ship an unpaid order');
this.status = 'shipped';
}
refund() { /* five more checks */ }
}The rules are real and the encoding is bad:
- The state machine is not written anywhere. It is distributed across the guard clauses of six methods, and reconstructing it means reading all of them.
- Adding a state means auditing every method. Introducing
partially_refundedrequires finding every check that must now consider it, and missing one is a silent bug. - Nothing prevents an invalid assignment.
order.status = 'delivered'from anywhere skips every rule. - The legal transitions cannot be shown, tested or documented as a whole.
The pattern
stateDiagram-v2
[*] --> Pending
Pending --> Paid: payment captured
Pending --> Cancelled: cancel
Paid --> Shipped: dispatch
Paid --> Cancelled: cancel
Shipped --> Delivered: delivery confirmed
Shipped --> Returned: return requested
Delivered --> Returned: return requested
Cancelled --> [*]
Returned --> [*]Each state becomes an object that knows what it permits:
const Paid = {
name: 'paid',
cancel: (order) => Cancelled,
ship: (order) => Shipped,
refund: (order) => { throw new IllegalTransition('refund', 'paid'); },
};
const Shipped = {
name: 'shipped',
cancel: () => { throw new IllegalTransition('cancel', 'shipped'); },
deliver: () => Delivered,
};The order delegates: this.state = this.state.cancel(this). There are no status comparisons anywhere, and the machine in the diagram above is now readable in the code.
What this buys, precisely
Adding a state is additive. A new state object declares what it permits. Existing states are unchanged unless they gain a transition into the new one — and where they should, the compiler or an exhaustiveness test says so.
Illegal transitions become impossible rather than checked. Shipped has no cancel that succeeds, so there is no code path that cancels a shipped order regardless of what a caller attempts.
The machine is testable as a machine. You can enumerate states and events and assert the whole transition table, which is a far stronger test than a handful of guard-clause cases.
Behaviour beyond legality lives with the state. A Shipped order calculates delivery estimates; a Pending one does not. Under a status flag, that logic is another set of conditionals.
Where it costs more than it saves
Two states. A boolean does not need a machine. Draft and published with one transition is a field.
Behaviour that does not vary. If every method behaves identically in every state and only the legality differs, a transition table is simpler than a set of objects: a map from (state, event) to the next state, with one function checking it.
States that are really combinations. If an order can be simultaneously "paid", "partially shipped" and "under review", these are orthogonal facts, not one machine. Multiplying them into PaidPartiallyShippedUnderReview is the subclass explosion under a new name — model them as separate fields, or as separate small machines.
Who owns the transition
Two arrangements, and the choice matters more than it first appears.
The state returns its successor, as above. The knowledge of what follows what lives in the state, which is cohesive but means each state knows about others — and a cycle of imports is common.
A transition table owned by the machine, with states holding only behaviour. The whole machine is readable in one place and states stay independent, at the cost of splitting "what is legal" from "what happens".
For a machine with genuinely different behaviour per state, the first is usually better. For a machine that is mostly about legality — most workflow and approval systems — the second is clearer, and it makes the table something a non-engineer can review.
Check your understanding
Common mistakes
A machine for two states. A boolean with one transition is a field.
Combining orthogonal dimensions into one machine. Three independent facts become eight states and then sixteen.
Public status assignment. If anything can set the field directly, every rule is optional.
Storing the state object rather than its name. The database becomes coupled to class names.
A lenient loader. An unknown stored value silently becoming a default is a silent data change.
States named for implementation phases. processing and updating are not domain states, and they attract logic that belongs elsewhere.
Transitions with side effects hidden inside them. A state change that also sends an email makes the machine untestable in isolation; emit an event and let a subscriber react.
Practice
A subscription has states trialling, active, past_due, paused, cancelled and expired, currently implemented as a status column with checks scattered across 14 methods. Design the refactor and say what you would get wrong if you rushed it.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
Recover the real machine before writing any of it. The documented one and the actual one differ.
Show a strong answer
Recover the actual machine first, from the code and the data — not from anyone's description. Two sources, and they will disagree:
- The guard clauses across the 14 methods give the intended transitions.
- The production data gives the real ones. A query over historical status changes — from an audit table, or from event logs — will show transitions nobody believes exist:
cancelledtoactivefrom a support tool,expiredtopast_duefrom a retry job. Every one of those is either a bug to fix or a transition to model, and deciding which is the substance of this refactor.
Write the transition table before any code, as data: rows of (state, event, next state). Get it reviewed by whoever owns billing, because half the questions are commercial rather than technical — can a paused subscription be cancelled, does a trial that lapses go to expired or cancelled, what happens to a past_due subscription that is paused. Those answers are not in the code.
Then implement. For this domain I would use a transition table owned by the machine rather than states returning successors, because subscription states are mostly about legality rather than differing behaviour, and a table is reviewable by a non-engineer. The 14 methods become one apply(event) plus per-state behaviour only where behaviour genuinely differs — billing amount, feature access.
Sequencing it safely:
- Add the machine alongside the existing checks and have it assert agreement in production without enforcing. Log every disagreement. This finds the transitions the data analysis missed, without risk.
- Only then enforce, once disagreements are zero for a period.
- Remove the scattered checks last.
What I would get wrong if I rushed it:
- Missing the transitions that only support tooling performs. They are not in the main code path and they are absolutely real. Enforcing a machine that forbids them breaks the support team's workflow on day one, and that is how a refactor gets rolled back.
- Treating paused as a state rather than a modifier. Paused subscriptions are often "active but not billing", and if a paused subscription retains a memory of what it was, it is a modifier over another state, not a peer of it. Getting this wrong produces
paused_from_activeandpaused_from_triallinglater. - Modelling expired and cancelled as distinct without being able to say what differs. If nothing behaves differently, they are one state with a reason field, and keeping both means every future rule must consider both.
- Putting side effects in transitions. Sending the dunning email inside the transition to
past_duemakes the machine impossible to test and impossible to replay over historical data — which is exactly what I would want to do to validate it.
Argue for and against putting the transition rules in the state objects versus a central table, for (a) a document workflow with approve/reject/recall, and (b) a network connection with connecting/open/closing/closed.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
Ask, for each, whether the states differ mainly in what they permit or in what they do.
Show a strong answer
(a) Document workflow — I would use a central table.
For the table: these states differ almost entirely in legality. A draft and a submitted document render the same, export the same, and differ in who may do what to them. When the distinction is legality, a table is the clearest possible expression — it is the whole policy on one screen, and it can be reviewed by the compliance or business owner who actually decides it. That reviewability is worth a great deal, because workflow rules change for non-technical reasons and get audited.
Against: the rules for a state end up split from any behaviour that state does have, so a reader chasing "what happens when a submitted document is approved" looks in two places.
What settles it: workflow rules change often and are argued about by non-engineers. Optimise for their readability.
(b) Network connection — I would put the rules in the state objects.
For state objects: here the states genuinely behave differently, and profoundly. send() on an open connection writes to a socket; on a connecting one it queues or rejects; on a closing one it must refuse; on a closed one it may reconnect or throw. That is real, substantial, per-state behaviour, and separating it from the legality rules would scatter closely related logic. Each state object becomes a coherent unit describing everything about being in that state.
Against: states know about each other, which produces circular references, and the machine as a whole is not visible in one place — a real cost when debugging a connection stuck in an unexpected state.
What settles it: the behaviour is the bulk of the code. Legality is a small part, and a diagram in a comment recovers the overview cheaply.
The general rule I would take from the pair: when states differ mainly in what they permit, centralise the rules as data; when they differ mainly in what they do, put both in the state objects. And in both cases, keep side effects out of the transitions — the connection should not open a socket in the transition, it should transition and let the caller act, or the machine cannot be tested without a network.
Key takeaways
- State gives each mode its own object, so behaviour and legal transitions live together instead of being scattered across guard clauses.
- It differs from Strategy in that a state decides its own successor — transitions are part of the model.
- Illegal transitions become impossible rather than checked, and the machine becomes testable as a whole.
- Two states do not need a machine, and orthogonal facts are not one machine — combining independent dimensions multiplies states.
- Persist the state name, not the object, and fail loudly on an unknown value or a removed state silently rewrites data.
- Put rules in the state objects when behaviour differs, in a central table when only legality differs — the table is reviewable by the people who own the policy.
- Keep side effects out of transitions; emit an event instead, or the machine cannot be tested or replayed.
Further reading
- Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994).
- David Harel, "Statecharts: A Visual Formalism for Complex Systems" (Science of Computer Programming, 1987) — orthogonal and hierarchical states.
- Martin Fowler, Domain-Specific Languages (Addison-Wesley, 2010) — state machines expressed as data.
- Eric Evans, Domain-Driven Design (Addison-Wesley, 2003) — modelling lifecycle in the ubiquitous language.
- Scott Wlaschin, Domain Modeling Made Functional (Pragmatic Bookshelf, 2018) — states as distinct types.