All questions

Statechart Engine (XState-lite)

Premium

Statechart Engine (XState-lite)

A state machine models a system as a fixed set of named states with explicit transitions between them: it is always in exactly one state, and an incoming event is the only thing that can move it to another. This is the model behind XState and the statechart formalism it implements. Instead of scattering booleans like isLoading and isError across a component and hoping they never contradict, a machine makes the legal states and the moves between them the single source of truth. You will build a small version of its core: createMachine, interpret, and assign.

A machine carries two things: a value (which state it is in) and a context (extended data that rides alongside, like a counter or a fetched payload). Transitions can run actions — plain effect functions — and can update context through assign. transition is a pure function; interpret turns a machine into a running service you can send events to and subscribe to.

Signature

const statechartXstateLite: {
  // Build a machine. transition is a PURE function: (state, event) -> next state.
  createMachine(config: MachineConfig): {
    initial: string;
    transition(state: State, event: Event): State;
  };

  // Run a machine as a live service.
  interpret(machine: Machine): {
    start(): Service;            // enter the initial state, run its entry actions
    send(event: Event): Service; // apply a transition, run its actions, notify subscribers
    getState(): State;           // the current { value, context }
    subscribe(listener: (state: State) => void): () => void; // returns an unsubscribe fn
  };

  // Mark a context update. The engine folds it into context; it is not an effect.
  assign(updater: (context: any, event: Event) => object): AssignAction;
};

type State = { value: string; context?: any };
type Event = string | { type: string; [key: string]: any };

Examples

const { createMachine } = statechartXstateLite;

const light = createMachine({
  initial: 'green',
  states: {
    green: { on: { NEXT: 'yellow' } },
    yellow: { on: { NEXT: 'red' } },
    red: { on: { NEXT: 'green' } },
  },
});

light.transition({ value: 'green', context: {} }, 'NEXT');
// -> { value: 'yellow', context: {}, actions: [] }

light.transition({ value: 'green', context: {} }, 'STOP');
// green has no handler for STOP -> the SAME state, unchanged
// -> { value: 'green', context: {} }
const { createMachine, interpret, assign } = statechartXstateLite;

const counter = createMachine({
  initial: 'active',
  context: { count: 0 },
  states: {
    active: {
      on: {
        // No target: internal transition. Stays in 'active', only updates context.
        INC: { actions: assign((ctx) => ({ count: ctx.count + 1 })) },
        // Guarded: RESET is only taken when count is above zero.
        RESET: { cond: (ctx) => ctx.count > 0, actions: assign(() => ({ count: 0 })) },
      },
    },
  },
});

const service = interpret(counter).start();
service.send('INC');
service.send('INC');
service.getState().context; // { count: 2 }

service.send('RESET'); // guard passes (2 > 0) -> count back to 0
service.getState().context; // { count: 0 }

Notes

  • State is { value, context }value is the current state name; context is the extended data (a counter, a payload) carried alongside it. Everything the machine remembers beyond "which state" lives in context.
  • transition is pure — it never mutates its input and never runs an effect. It returns the next state plus the plain action functions in an actions array for the caller to run. interpret is the piece that actually runs them.
  • Unknown event, same state — if the current state has no handler for the event type, return the exact same state object. Nothing moves and nothing runs, so callers can detect a no-op by reference.
  • Guards gate a transition — a cond(context, event) that returns false cancels the move. The machine stays in its current state and runs no actions.
  • Action order is exit, then transition, then entry — a transition with a target leaves the source state (its exit actions) and enters the target (its entry actions), with the transition's own actions between them. A transition with no target is internal: it stays put and skips exit and entry.
  • Out of scope — nested and parallel states, delayed (after) transitions, invoked services, and history. Flat states only; guards, entry/exit/transition actions, and context via assign are the whole job.

FAQ

What is the difference between transition and interpret?
transition is a pure function: given a state and an event it returns the next state and the actions to run, without changing anything or causing a side effect. interpret wraps a machine in a live service that holds the current state, actually runs those action functions, and notifies subscribers on every send.
How is assign different from a regular action?
A regular action is an effect: a function the interpreter runs for its side effect. assign is a context update: its updater returns a partial object that the engine merges over the current context to produce a new one. The engine folds assigns into the next context during transition and never runs them as effects.
Why do entry, transition, and exit actions run in a fixed order?
A transition with a target leaves the source state and arrives in the target, so it runs the source's exit actions, then the transition's own actions, then the target's entry actions. A transition with no target is internal: it stays put and skips exit and entry, running only its own actions.

Unlock the solution & editor

  • Runnable editor + tests

    Solve in the browser with instant Jest feedback.

  • Detailed solutions

    Walkthroughs, edge cases, and complexity notes.

  • Multi-framework variants

    React, Vue, Vanilla, Angular — same question, different stacks.

Upgrade to Premium