GUIDE · Behavioral

State

Allow an object to alter its behavior when its internal state changes; the object will appear to change its class.

lesson2 sectionsruns in browserBehavioral17 asserts121 lines

At a glance

INTENT
Allow an object to alter its behavior when its internal state changes; the object will appear to change its class.
PROBLEM
if (status === 'paid') ... else if (status === 'shipped') ... repeated inside pay(), ship(), cancel(), refund() — every new status touches every method, and illegal transitions slip through.
SAY
Participants: Context (Order — holds the current State, delegates to it), State (interface: one method per event), ConcreteState (Created/Paid/Shipped — implements only what's legal and picks the next state). Pitch: "a finite state machine where each state is an object."
SEEN IN
XState / statecharts; Promise (pending → fulfilled | rejected, then frozen); WebSocket/XHR readyState; Node stream modes (paused/flowing/ended); TCP connection states; UI flows (checkout wizard, upload idle→uploading→done|error); order/subscription lifecycles; reducers switching on state.status.
VS
Strategy: the CLIENT picks the algorithm and strategies don't know each other; State: the STATE ITSELF triggers transitions to the next state, so the context's behavior changes over time. Same structure (context delegates to a swappable object), opposite intent.

The idea#

Picture a vending machine. The same buttons stay on the front all day. What they do depends on the mode the machine is in. While it is idle, Coin is legal and Vend is not. After the coin, Vend is legal and Coin is a shrug. While it is already dropping a snack, both buttons are the wrong move. You do not want one giant function full of "if idle, else if paid, else if vending" copied into every button.

That mode is a state object. The machine is the context. It holds the current mode and forwards every button press to it. Each concrete state implements only the moves that are legal there, and that state picks the next mode. The idle object's coin swaps in the paid object. The paid object's vend swaps in the vending object. Asking a mode for a button it does not allow throws, so a bad press does not slide through as a quiet no-op you notice after the snack fails to fall.

An order is the same machine. Created, paid, shipped, delivered, and cancelled are modes. Pay, ship, deliver, and cancel are the buttons. The order does not switch on a status string inside every method. It delegates. The book line is a finite state machine where each state is an object. The machine looks like it changed class, because ship while created and ship while paid are not the same function. A different object is behind the panel.

Why it matters#

The bug this pattern prevents is a status string checked in four methods that slowly disagree. pay thinks a brand-new order can ship. ship thinks it cannot. cancel refunds money that was never inserted, because the refund line was copied from the paid mode into the idle mode. Every new mode means opening every button, and the button you forget becomes a bad transition. The machine takes the coin and also vends from empty.

You already live with machines like this. A promise is pending, then fulfilled or rejected, and then the buttons freeze. A socket has a ready state and ignores the wrong call. An upload widget moves idle, uploading, done, or error, and Start in the done mode must not begin a second upload by accident. Checkout steps and subscription lifecycles are the same front panel, whether you build the modes as classes or as a table of allowed presses.

Strategy wears the same jacket and means the opposite. In both patterns a context holds a swappable object and calls it. With strategy, you pick the chip from the outside, and the chips do not know each other. A flat-rate shipper does not decide, on its own, to become a weight-based shipper after lunch. With state, the mode itself triggers the next mode. Paid knows that the ship button leads to shipped. The client did not reach in and swap the object. The machine did, because someone pressed a legal button.

A small example#

JS
function vend() {  const modes = {    idle: { coin: 'paid' },    paid: { vend: 'vending' },    vending: {},  };  let mode = 'idle';  return {    get mode() { return mode; },    press(event) {      const next = modes[mode][event];      if (!next) throw new Error(`cannot ${event} when ${mode}`);      mode = next;    },  };} const machine = vend();machine.press('coin');machine.press('vend');

press looks at the row for the current mode. Coin from idle moves the machine to paid. Vend from paid moves it to vending. Vend from idle throws cannot vend when idle, because that button is not on the idle row. Same panel. Different mode.

What the code below is doing#

The sheet shows the same machine three ways.

The first shape uses classes so the names are visible. OrderState is the mode interface. pay, ship, deliver, and cancel all default to illegal, which throws cannot ${event} when ${name}. CreatedState is idle: pay and cancel are legal. Pay records the cents taken and swaps in PaidState. Cancel swaps in CancelledState and refunds nothing, because no coin went in. PaidState allows ship, which writes a tracking code and moves to ShippedState, and cancel, which records the refund before cancelling. ShippedState only allows deliver. There is no cancel on that panel, so the base class throws. DeliveredState and CancelledState add no buttons. They are terminal modes. Order is the machine. It starts on created, and each method is one line of delegation. No status if sits inside pay or ship.

The asserts follow two walks up to the same glass. Pay, ship, deliver ends at delivered, the history is created then paid then shipped then delivered, and cancel throws. Pay then cancel refunds the full amount: same cancel button, different mode, so it gives the money back. Cancel before pay refunds zero. Ship from created throws. Cancel after ship throws, because the shipped mode left that button off on purpose.

The second shape is that panel as a table. documentMachine maps draft, review, published, and archived to the presses each mode allows. send throws if the press is not on the current row. Review's entry runs every time you arrive, so submit, reject, submit, approve leaves the review count at 2 and the mode at published. Archived's row is empty: a terminal mode, written as data.

The third shape is a reducer keyed by status. Idle can start. Uploading can progress, finish, or fail. Done has no start key, so a late start leaves the machine sitting there. The snack already fell. Mashing the button does not vend twice.

When you talk about it, say this: a finite state machine where each state is an object.

Everything below is the demonstration. Same idea, written as code you can run.

JSsystem-design/design-patterns/behavioral/state.js · setup
'use strict';const assert = require('node:assert/strict');
01

Classic shape (classes, so the participant names are visible)#

JS
class OrderState {                                        // State — default: every event is illegal  get name() { return this.constructor.name.replace('State', '').toLowerCase(); }  pay() { return this.illegal('pay'); }  ship() { return this.illegal('ship'); }  deliver() { return this.illegal('deliver'); }  cancel() { return this.illegal('cancel'); }  illegal(event) { throw new Error(`cannot ${event} when ${this.name}`); }}class CreatedState extends OrderState {                  // ConcreteState  pay(order) { order.paidCents = order.totalCents; order.transition(new PaidState()); }  cancel(order) { order.transition(new CancelledState()); }}class PaidState extends OrderState {                     // ConcreteState  ship(order) { order.tracking = `TRK-${order.id}`; order.transition(new ShippedState()); }  cancel(order) { order.refundCents = order.paidCents; order.transition(new CancelledState()); }   // refund path}class ShippedState extends OrderState {                  // ConcreteState — no cancel(): illegal by omission  deliver(order) { order.transition(new DeliveredState()); }}class DeliveredState extends OrderState {}               // terminalclass CancelledState extends OrderState {}               // terminal class Order {                                             // Context — no conditionals, pure delegation  constructor(id, totalCents) {    Object.assign(this, { id, totalCents, paidCents: 0, refundCents: 0, tracking: null, history: ['created'] });    this.state = new CreatedState();  }  transition(next) { this.state = next; this.history.push(next.name); }  get status() { return this.state.name; }  pay() { this.state.pay(this); return this; }  ship() { this.state.ship(this); return this; }  deliver() { this.state.deliver(this); return this; }  cancel() { this.state.cancel(this); return this; }} const o1 = new Order('o1', 4999).pay().ship().deliver(); const o2 = new Order('o2', 1000).pay().cancel();const o3 = new Order('o3', 500).cancel();
Verified by 9 asserts
JS
assert.equal(o1.status, 'delivered');assert.deepEqual(o1.history, ['created', 'paid', 'shipped', 'delivered']);assert.equal(o1.tracking, 'TRK-o1');assert.throws(() => o1.cancel(), /cannot cancel when delivered/);assert.equal(o2.status, 'cancelled');assert.equal(o2.refundCents, 1000);                       // same event, different behavior per stateassert.equal(o3.refundCents, 0);                          // cancelled before paying → nothing to refundassert.throws(() => new Order('o4', 1).ship(), /cannot ship when created/);assert.throws(() => new Order('o5', 1).pay().ship().cancel(), /cannot cancel when shipped/);
02

Idiomatic JS shape: a transition table (what XState boils down to) + a tiny interpreter#

JS
const documentMachine = {  initial: 'draft',  states: {    draft: { on: { submit: 'review' } },    review: { on: { approve: 'published', reject: 'draft' }, entry: (ctx) => { ctx.reviews += 1; } },    published: { on: { archive: 'archived', unpublish: 'draft' } },    archived: { on: {} },  },};const interpret = (machine, ctx = {}) => {  let current = machine.initial;  return {    ctx,    get state() { return current; },    can: (event) => event in machine.states[current].on,    send(event) {      const next = machine.states[current].on[event];      if (!next) throw new Error(`no transition for "${event}" from "${current}"`);      current = next;      machine.states[next].entry?.(ctx);      return this;    },  };}; const docMachine = interpret(documentMachine, { reviews: 0 });docMachine.send('submit').send('reject').send('submit').send('approve'); // Status-keyed reducer — the same idea in everyday React/Redux codeconst uploadReducer = (s, e) => ({  idle: { start: { status: 'uploading', pct: 0 } },  uploading: { progress: { ...s, pct: e.pct }, done: { status: 'done' }, fail: { status: 'error', msg: e.msg } },  done: {}, error: { retry: { status: 'uploading', pct: 0 } },})[s.status][e.type] ?? s;let up = { status: 'idle' };up = uploadReducer(up, { type: 'start' });up = uploadReducer(up, { type: 'progress', pct: 40 });up = uploadReducer(up, { type: 'done' });
Verified by 8 asserts
JS
assert.equal(docMachine.state, 'draft');assert.equal(docMachine.can('approve'), false);assert.equal(docMachine.state, 'published');assert.equal(docMachine.ctx.reviews, 2);                  // entry action ran on each visit to reviewassert.throws(() => docMachine.send('submit'), /no transition for "submit" from "published"/);assert.deepEqual(Object.keys(documentMachine.states.archived.on), []);   // terminal state, visible as dataassert.deepEqual(up, { status: 'done' });assert.deepEqual(uploadReducer(up, { type: 'start' }), { status: 'done' });   // ignored in terminal state

See also