// ───────────────────────────────────────────────────────────────────────────── // STATE [Behavioral] // ───────────────────────────────────────────────────────────────────────────── // 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. // ───────────────────────────────────────────────────────────────────────────── 'use strict'; const assert = require('node:assert/strict'); // ── Classic shape (classes, so the participant names are visible) 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 {} // terminal class 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(); 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/); const o2 = new Order('o2', 1000).pay().cancel(); assert.equal(o2.status, 'cancelled'); assert.equal(o2.refundCents, 1000); // same event, different behavior per state const o3 = new Order('o3', 500).cancel(); assert.equal(o3.refundCents, 0); // cancelled before paying → nothing to refund assert.throws(() => new Order('o4', 1).ship(), /cannot ship when created/); assert.throws(() => new Order('o5', 1).pay().ship().cancel(), /cannot cancel when shipped/); // ── Idiomatic JS shape: a transition table (what XState boils down to) + a tiny interpreter 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 }); assert.equal(docMachine.state, 'draft'); assert.equal(docMachine.can('approve'), false); docMachine.send('submit').send('reject').send('submit').send('approve'); assert.equal(docMachine.state, 'published'); assert.equal(docMachine.ctx.reviews, 2); // entry action ran on each visit to review assert.throws(() => docMachine.send('submit'), /no transition for "submit" from "published"/); assert.deepEqual(Object.keys(documentMachine.states.archived.on), []); // terminal state, visible as data // Status-keyed reducer — the same idea in everyday React/Redux code const 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' }); assert.deepEqual(up, { status: 'done' }); assert.deepEqual(uploadReducer(up, { type: 'start' }), { status: 'done' }); // ignored in terminal state console.log('ok: design-patterns/behavioral/state.js');