codejump

AcademyDesign Patterns

State pattern

Letting an object swap what it can do next, decided from inside itself, the state class and transition table it costs, and two states that disagree who owns a transition.

6 min readUpdated

3 exercises

Waiting at the end of this page.

State looks like Strategy from the outside, the same interchangeable-implementation shape, but is chosen by the object itself, and changes over time as the object reacts to what happens to it.

State looks like Strategy wearing a different name: the same shape, an interface with interchangeable implementations a class delegates to. But the intent flips. A Strategy is picked once, from outside, and stays fixed. A State is picked by the object itself, and changes over time as the object reacts to what happens to it, with each state deciding both what it can do and which state comes next.

The problem it solves

A phone can be idle or in a call. Picking up while already in a call makes no sense, and neither does hanging up while idle. A first version uses a status field and checks it everywhere:

TypeScript
pickUp() {
  if (this.status === 'idle') this.status = 'active';
  else if (this.status === 'active') throw new Error('already active');
}
  • Every method repeats the same checks. With five states and six actions, that is thirty combinations spread across the class.
  • Adding a state means editing every method.
  • Illegal transitions are easy to miss. Nothing forces you to decide what happens for each pair.

The fix is one small class per state. Each state defines what every action does while in that state, and which state comes next.

Structure

Each state handles an action by moving to the next state, or refusing. The phone just delegates to its current state
Each state handles an action by moving to the next state, or refusing. The phone just delegates to its current state

Three parts:

  • A state interface: the actions the context supports.
  • Concrete states (IdleState, ActiveState): each implements the actions for that state, and returns the next state.
  • The context (Phone): holds the current state and delegates every action to it.
class IdleState {
  pickUp() { return new ActiveState(); }
  hangUp() { throw new Error('already idle'); }
}
class ActiveState {
  pickUp() { throw new Error('already active'); }
  hangUp() { return new IdleState(); }
}

class Phone {
  constructor() { this.state = new IdleState(); }
  pickUp() { this.state = this.state.pickUp(); }
  hangUp() { this.state = this.state.hangUp(); }
}

// const phone = new Phone();
// phone.pickUp();  → state becomes ActiveState
// phone.hangUp();  → state becomes IdleState again
// phone.hangUp();  → throws 'already idle'

Phone has no if on a status. Asking an idle phone to hang up throws, because IdleState.hangUp says so. The rules live with the state they belong to.

Where it is used in real systems

An order moves through states, and each state allows only certain actions
An order moves through states, and each state allows only certain actions
Use caseThe states
Order lifecyclePending, paid, shipped, delivered, cancelled, refunded.
Subscription statusTrial, active, past due, cancelled.
UI componentsIdle, loading, success, error for a request.
Media playersStopped, playing, paused, buffering.
DocumentsDraft, in review, approved, published.
ConnectionsConnecting, open, closing, closed.
Game charactersStanding, jumping, ducking, attacking.
Authentication flowsSigned out, verifying, signed in.

If you have a status field and several methods that all start with if (status === ...), you are looking at a State machine written by hand.

States you already use

  • Promises. Pending, fulfilled or rejected, and the transitions are one-way.
  • Fetching in React Query or SWR. isLoading, isSuccess and isError, each rendering something different.
  • WebSocket `readyState`. Connecting, open, closing, closed.
  • Lemon Squeezy subscription status. on_trial, active, past_due, cancelled, each allowing different actions.
  • XState models whole applications as state machines.
  • Git. A rebase in progress, a merge conflict and a clean tree each allow different commands.

State classes or a table

You do not always need a class per state. For many cases a transition table is smaller and easier to read:

TypeScript
type Status = 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled';

const TRANSITIONS: Record<Status, readonly Status[]> = {
  pending: ['paid', 'cancelled'],
  paid: ['shipped', 'cancelled'],
  shipped: ['delivered'],
  delivered: [],
  cancelled: [],
};

function canMove(from: Status, to: Status): boolean {
  return TRANSITIONS[from].includes(to);
}

Use a table when states differ only in which transitions are allowed. Use State classes when each state also has different behavior, not just different permissions.

State or Strategy

StrategyState
Who choosesThe client, from outsideThe object itself, in response to events
Changes over timeRarelyConstantly
Implementations know each otherNoYes, each knows which state comes next
PurposePick an algorithmModel a lifecycle

When not to use it

  • Two or three states and simple rules. A status field and one check may be clearer.
  • States differ only in permissions. A transition table is smaller.
  • Many states with mostly shared behavior. One class per state means a lot of duplication. Use a table, or a library like XState.
  • State stored in a database. The persisted value is a string. You still need a mapping from that string to the class when you load it.

What interviewers ask

  • "What is the State pattern?" An object changes its behavior when its internal state changes, by delegating to a state object.
  • "State versus Strategy?" Same structure, different intent. State changes itself over time. Strategy is picked by the client.
  • "How does it remove `if/else`?" Each state class holds the behavior for that state, so the context has no branching.
  • "Where would you use it in a backend?" Order or subscription lifecycles, with valid transitions enforced.
  • "How do you handle invalid transitions?" The state throws or returns an error, instead of the context checking.

The mistake to avoid

Letting the state be changed from outside. If any code can write phone.state = new ActiveState(), the transition rules are no longer enforced. Keep the current state private, and let only state objects decide what comes next.

The mechanism above, an object delegating to a replaceable helper that can replace itself, is the same in JavaScript, Python or Java. Only the syntax changes.

Now practice it

1 challenge · 2 quizzes

Reading this page is the cheap half. These are the exercises that make you use it.