A finite state machine describes something that is always in exactly one of a fixed list of states, and that moves from one state to another only when an event happens. Finite means the list of states is known in advance. For each state, you write down which events it responds to and which state each one leads to; each of those moves is called a transition. In the state machines used in UI code, an event that the current state has no transition for is ignored, so the machine can't end up anywhere you didn't plan for.
It replaces state spread over several booleans. A door described by isOpen and isLocked can be open and locked at once, which makes no sense, and every piece of code that changes them has to remember the rules. A state machine names the states, closed, open, and locked, and keeps every rule in one table. Each row is a state, each column an event, and each cell the state that event leads to:
| State | OPEN | CLOSE | LOCK | UNLOCK |
|---|---|---|---|---|
closed | open | Ignored | locked | Ignored |
open | Ignored | closed | Ignored | Ignored |
locked | Ignored | Ignored | Ignored | closed |
Here's the door, starting closed, sent one event at a time:
| Event | Before | After | Why |
|---|---|---|---|
LOCK | closed | locked | A closed door can be locked. |
OPEN | locked | locked | Ignored: locked has no transition for OPEN. |
UNLOCK | locked | closed | Unlocking leaves the door closed. |
OPEN | closed | open | Now it opens. |
LOCK | open | open | Ignored: an open door can't be locked. |
Events can carry data along with their type, such as { type: "UNLOCK", code: "1234" }. A guard is a condition attached to a transition and checked when the event arrives: the transition is taken only if the guard passes. Give UNLOCK a guard that checks the code, and the right code leads to closed, while a wrong one leaves the door locked, as if the event had been ignored. One event can also have several guarded transitions, tried in order: the first whose guard passes is taken.
You meet state machines wherever a UI moves through fixed stages: a request that's idle, loading, success, or error; the steps of a checkout; a video that's playing or paused. A promise is a tiny one: it starts pending and moves once, to fulfilled or rejected, and then never changes. The XState library describes machines as config objects in this same shape: an initial state and, for each state, an on object that maps events to the state they lead to.
Write createMachine(config). The config names the state to start in and, for each state, which events lead where:
const door = createMachine({
initial: "closed",
states: {
closed: { on: { OPEN: "open", LOCK: "locked" } },
open: { on: { CLOSE: "closed" } },
locked: {
on: {
UNLOCK: [
{ target: "closed", guard: (event) => event.code === "1234" },
{ target: "alarm", guard: (event) => event.attempt >= 3 },
],
},
},
alarm: {},
},
});
It returns a machine. state is a getter for the current state's name, which starts as config.initial.
send(event) takes an event and returns the state after it. An event is an object with a type, or a string, which is short for { type: thatString }.
The event's type looks up a transition in the current state's on, which may be missing. A transition is a target state's name, an object { target, guard }, or an array of those.
A guard is called with the event object, and its transition is taken only if it returns something truthy. A transition without a guard is always taken. In an array, the first transition that can be taken wins. If the state has no transition for the event, or none can be taken, nothing happens.
can(event) returns whether send(event) would take a transition, without taking it.
subscribe(listener) calls listener(state, event) after every transition taken, with the new state and the event object. It returns a function that unsubscribes.
door.send("LOCK"); // "locked"
door.send({ type: "UNLOCK", code: "0000" }); // "locked": no guard passed
door.can({ type: "UNLOCK", code: "1234" }); // true
door.send({ type: "UNLOCK", code: "1234" }); // "closed"
door.send("CLOSE"); // "closed": nothing for CLOSE here
const machine = createMachine({ initial: "idle", states: { idle: {}, busy: {} } });
expect(machine.state).toBe("idle");
const light = createMachine({
initial: "green",
states: {
green: { on: { NEXT: "yellow" } },
yellow: { on: { NEXT: "red" } },
red: { on: { NEXT: "green" } },
},
});
expect(light.send({ type: "NEXT" })).toBe("yellow");
expect(light.send({ type: "NEXT" })).toBe("red");
expect(light.state).toBe("red");
const light = createMachine({
initial: "green",
states: { green: { on: { NEXT: "yellow" } }, yellow: { on: { NEXT: "red" } }, red: {} },
});
expect(light.send("NEXT")).toBe("yellow");
expect(light.state).toBe("yellow");
const door = createMachine({
initial: "closed",
states: { closed: { on: { OPEN: "open" } }, open: { on: { CLOSE: "closed" } }, broken: {} },
});
expect(door.send("CLOSE")).toBe("closed");
expect(door.state).toBe("closed");
const done = createMachine({ initial: "done", states: { done: {} } });
expect(done.send("ANYTHING")).toBe("done");
const guard = spy((event) => event.code === "1234");
const lock = createMachine({
initial: "locked",
states: { locked: { on: { UNLOCK: { target: "open", guard } } }, open: {} },
});
expect(lock.send({ type: "UNLOCK", code: "0000" })).toBe("locked");
expect(guard).toHaveBeenCalledWith({ type: "UNLOCK", code: "0000" });
expect(lock.send({ type: "UNLOCK", code: "1234" })).toBe("open");
const config = {
initial: "start",
states: {
start: {
on: {
GO: [
{ target: "small", guard: (event) => event.n < 10 },
{ target: "medium", guard: (event) => event.n < 100 },
{ target: "large" },
],
},
},
small: {},
medium: {},
large: {},
},
};
expect(createMachine(config).send({ type: "GO", n: 5 })).toBe("small");
expect(createMachine(config).send({ type: "GO", n: 50 })).toBe("medium");
expect(createMachine(config).send({ type: "GO", n: 500 })).toBe("large");
const lock = createMachine({
initial: "locked",
states: {
locked: { on: { UNLOCK: { target: "open", guard: (event) => event.code === "1234" } } },
open: { on: { LOCK: "locked" } },
},
});
expect(lock.can({ type: "UNLOCK", code: "0000" })).toBe(false);
expect(lock.can({ type: "UNLOCK", code: "1234" })).toBe(true);
expect(lock.can("LOCK")).toBe(false);
expect(lock.state).toBe("locked");
const light = createMachine({
initial: "green",
states: { green: { on: { NEXT: "yellow" } }, yellow: { on: { NEXT: "red" } }, red: {} },
});
const listener = spy();
light.subscribe(listener);
light.send("NEXT");
light.send("NOPE");
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith("yellow", { type: "NEXT" });
const light = createMachine({
initial: "green",
states: { green: { on: { NEXT: "yellow" } }, yellow: { on: { NEXT: "red" } }, red: {} },
});
const listener = spy();
const unsubscribe = light.subscribe(listener);
light.send("NEXT");
unsubscribe();
light.send("NEXT");
expect(listener).toHaveBeenCalledTimes(1);
expect(light.state).toBe("red");
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with Finite state machine. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.