Skip to content

Traffic Light State Machine

Multi-state machine with meta.route on every state, demonstrating cyclic transitions and formatPlayRouteTransitions.

Use Case

This example models a traffic light with a red → green → yellow → red cycle. Because traffic lights map naturally to URLs (each light colour is a distinct page in a web app), it is a good vehicle for showing how meta.route and formatPlayRouteTransitions work together.

Applicable patterns:

  • Traffic light controllers
  • Multi-step workflows with repeating cycles
  • Tab or wizard navigation where each step has a URL
  • Game state loops with distinct URL states

Complete Code

import { setup } from "xstate";
import { definePlayer, formatPlayRouteTransitions } from "@xmachines/play-xstate";
// 1. Typed setup
const trafficSetup = setup({
types: {
context: {} as {
params: Record<string, string>;
query: Record<string, string>;
},
events: {} as
| { type: "timer" }
| {
type: "play.route";
to: string;
params?: Record<string, string>;
query?: Record<string, string>;
},
input: {} as undefined,
},
});
// 2. Machine with meta.route on every state
// formatPlayRouteTransitions() reads each state's id + meta.route and
// auto-generates the root-level play.route event handlers.
const trafficMachine = trafficSetup.createMachine(
formatPlayRouteTransitions({
id: "traffic",
initial: "red",
context: {
params: {},
query: {},
},
states: {
red: {
id: "red",
meta: { route: "/red" },
on: { timer: "green" },
},
green: {
id: "green",
meta: { route: "/green" },
on: { timer: "yellow" },
},
yellow: {
id: "yellow",
meta: { route: "/yellow" },
on: { timer: "red" },
},
},
}),
);
// 3. Player factory
const createPlayer = definePlayer({ machine: trafficMachine });
// 4. Create and start actor
const actor = createPlayer();
actor.start();
// 5. Read current route via signal
console.log(actor.currentRoute.get()); // "/red"
// 6. Advance with domain events
actor.send({ type: "timer" });
console.log(actor.currentRoute.get()); // "/green"
actor.send({ type: "timer" });
console.log(actor.currentRoute.get()); // "/yellow"
actor.send({ type: "timer" });
console.log(actor.currentRoute.get()); // "/red"
// 7. Or navigate directly via play.route events (generated by formatPlayRouteTransitions)
actor.send({ type: "play.route", to: "#green" });
console.log(actor.currentRoute.get()); // "/green"
// 8. Cleanup
actor.stop();

How formatPlayRouteTransitions Works

formatPlayRouteTransitions crawls every state that has both an id and a meta.route, then generates root-level play.route event handlers of the form:

// Auto-generated by formatPlayRouteTransitions — you don't write this manually:
on: {
"play.route": [
{ target: ".red", guard: ({ event }) => event.to === "#red", reenter: true, actions: assign({ params, query }) },
{ target: ".green", guard: ({ event }) => event.to === "#green", reenter: true, actions: assign({ params, query }) },
{ target: ".yellow", guard: ({ event }) => event.to === "#yellow", reenter: true, actions: assign({ params, query }) },
],
}

Requirements:

  • Every routable state must have both id (the #id navigation target) and meta.route (the URL template).
  • The machine’s context must include params and query fields (both Record<string, string>), because formatPlayRouteTransitions assigns them on every play.route transition.

actor.currentRoute Signal

actor.currentRoute is a Signal.Computed<string | null> derived from the active state’s meta.route template and context.params. Route signals let router bridges observe URL changes reactively without polling.

import { watchSignal } from "@xmachines/play-signals";
const unwatch = watchSignal(actor.currentRoute, () => {
console.log("URL changed to:", actor.currentRoute.get());
});
// later...
unwatch();

Key Concepts

  • meta.route: Marks a state as routable. The value is a URL template (e.g. "/profile/:username").
  • formatPlayRouteTransitions: Utility that auto-generates play.route handlers from id + meta.route pairs. Wrap your machine config before passing to createMachine.
  • actor.currentRoute: TC39 Signal.Computed containing the resolved URL for the active state.
  • play.route events: Navigation events with a to: "#stateId" target. Use to: "#red" (state ID), not to: "/red" (URL path).

Next Steps