Skip to the code
cf.

Finite state machine

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

What the tests check

  1. starts in the initial state
    const machine = createMachine({ initial: "idle", states: { idle: {}, busy: {} } });
    expect(machine.state).toBe("idle");
    
  2. send follows a transition and returns the new state
    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");
    
  3. a string event is short for { type }
    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");
    
  4. ignores events the current state has no transition for
    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");
    
  5. a guard gets the event and decides whether its transition is taken
    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");
    
  6. takes the first transition in a list that can be taken
    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");
    
  7. can() says whether send() would take a transition, without taking it
    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");
    
  8. subscribers get the new state and the event after each transition taken
    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" });
    
  9. the function subscribe returns stops the notifications
    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");
    

Learn more

solution.jsSaved in this browser

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).