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.
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 };
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 }
{ 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.cond(context, event) that returns false cancels the move. The machine stays in its current state and runs no actions.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.after) transitions, invoked services, and history. Flat states only; guards, entry/exit/transition actions, and context via assign are the whole job.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.