Hierarchical Traffic Light
A traffic light that demonstrates the core tradeoff in hierarchical state machines: you can model the same behavior with a flattened machine (all states at the top level, explicit transitions everywhere) or a nested machine (child states inherit transitions from parents via propagation).
import { defineStates, matchina } from "matchina";import { submachine, nestedHsmRoot } from "matchina/hsm";
// 1. Define the Child Machine (Light Cycle)// We need a factory for the child machine so it can be instantiated freshlyconst lightCycleStates = defineStates({ Red: undefined, Green: undefined, Yellow: undefined,});
const createLightCycle = () => matchina( lightCycleStates, { Red: { tick: "Green" }, Green: { tick: "Yellow" }, Yellow: { tick: "Red" }, }, lightCycleStates.Red() );
// 2. Define the Parent Machine (Controller)// We use `submachine` to embed the child machine factoryconst controllerStates = defineStates({ Broken: undefined, Working: submachine(createLightCycle), Maintenance: undefined,});
const createController = () => matchina( controllerStates, { Broken: { repair: "Working", maintenance: "Maintenance" }, Working: { break: "Broken", maintenance: "Maintenance" }, Maintenance: { complete: "Working" }, }, controllerStates.Working() );
// 3. Create the Hierarchical Machine// This wraps the controller with propagation logicexport function createPropagatingTrafficLight() { const root = createController(); return nestedHsmRoot(root);}import { createHSM } from "matchina/hsm";
export function createFlatTrafficLight() { return createHSM({ initial: "Working", states: { Broken: { on: { repair: "Working", maintenance: "Maintenance", }, },
// Working is a hierarchical state with light cycle substates Working: { initial: "Red", states: { Red: { on: { tick: "Green", }, }, Green: { on: { tick: "Yellow", }, }, Yellow: { on: { tick: "Red", }, }, }, // Parent-level transitions apply to all child states on: { break: "^Broken", maintenance: "^Maintenance", }, },
Maintenance: { on: { complete: "Working", }, }, }, });}import { useMachine } from "matchina/react";
interface TrafficLightViewNestedProps { machine: any;}
const glowColor: Record<string, string> = { red: "shadow-red-500/60", yellow: "shadow-yellow-400/60", green: "shadow-green-500/60",};
function Light({ color, active }: { color: "red" | "yellow" | "green"; active: boolean }) { const offColor = { red: "bg-[oklch(0.22_0.04_15)]", yellow: "bg-[oklch(0.22_0.04_85)]", green: "bg-[oklch(0.22_0.04_142)]" }[color]; const onColor = { red: "bg-red-500", yellow: "bg-yellow-400", green: "bg-green-500" }[color]; return ( <div className={`w-12 h-12 rounded-full transition-all duration-200 ${active ? `${onColor} shadow-lg ${glowColor[color]}` : offColor}`} /> );}
export function TrafficLightViewNested({ machine }: TrafficLightViewNestedProps) { const change = useMachine(machine) as { to: { key: string; data?: any } }; const state = change.to; const send = (event: string) => machine.send(event);
const parent = state.key; let child: string | null = null; if (state.data?.machine) { const childState = state.data.machine.getState(); if (childState) child = childState.key; }
const isWorking = parent === "Working"; const lightColor = child ?? "off";
return ( <div className="flex flex-col items-center gap-5"> {/* Housing */} <div className="flex flex-col items-center gap-3 bg-[oklch(0.18_0.01_240)] rounded-2xl px-5 py-6 border border-[oklch(0.25_0.01_240)]"> <Light color="red" active={isWorking && lightColor === "Red"} /> <Light color="yellow" active={isWorking && lightColor === "Yellow"} /> <Light color="green" active={isWorking && lightColor === "Green"} /> </div>
{/* State badges */} <div className="flex gap-2 flex-wrap justify-center"> <span className="badge badge-outline text-[10px] font-mono">{parent}</span> {child && <span className="badge badge-outline text-[10px] font-mono">{child}</span>} </div>
{/* Controls */} <div className="flex flex-wrap gap-2 justify-center"> {isWorking && ( <button onClick={() => send("tick")} className="btn btn-primary btn-sm">Tick</button> )} {parent === "Broken" && ( <button onClick={() => send("repair")} className="btn btn-outline btn-sm">Repair</button> )} {parent === "Maintenance" && ( <button onClick={() => send("complete")} className="btn btn-outline btn-sm">Complete</button> )} {(isWorking || parent === "Broken") && ( <button onClick={() => send("maintenance")} className="btn btn-outline btn-sm">Maintenance</button> )} {isWorking && ( <button onClick={() => send("break")} className="btn btn-destructive btn-sm">Break</button> )} </div> </div> );}import { useMachine } from "matchina/react";
interface TrafficLightViewFlatProps { machine: any;}
const glowColor: Record<string, string> = { red: "shadow-red-500/60", yellow: "shadow-yellow-400/60", green: "shadow-green-500/60",};
function Light({ color, active }: { color: "red" | "yellow" | "green"; active: boolean }) { const offColor = { red: "bg-[oklch(0.22_0.04_15)]", yellow: "bg-[oklch(0.22_0.04_85)]", green: "bg-[oklch(0.22_0.04_142)]" }[color]; const onColor = { red: "bg-red-500", yellow: "bg-yellow-400", green: "bg-green-500" }[color]; return ( <div className={`w-12 h-12 rounded-full transition-all duration-200 ${active ? `${onColor} shadow-lg ${glowColor[color]}` : offColor}`} /> );}
export function TrafficLightViewFlat({ machine }: TrafficLightViewFlatProps) { const change = useMachine(machine) as { to: { key: string; data?: any } }; const state = change.to; const send = (event: string) => machine.send(event);
const parts = state.key.split("."); const parent = parts[0]; const child = parts[1] as string | undefined; const isWorking = parent === "Working"; const lightColor = child ?? "off";
return ( <div className="flex flex-col items-center gap-5"> {/* Housing */} <div className="flex flex-col items-center gap-3 bg-[oklch(0.18_0.01_240)] rounded-2xl px-5 py-6 border border-[oklch(0.25_0.01_240)]"> <Light color="red" active={isWorking && lightColor === "Red"} /> <Light color="yellow" active={isWorking && lightColor === "Yellow"} /> <Light color="green" active={isWorking && lightColor === "Green"} /> </div>
{/* State badges */} <div className="flex gap-2 flex-wrap justify-center"> <span className="badge badge-outline text-[10px] font-mono">{parent}</span> {child && <span className="badge badge-outline text-[10px] font-mono">{child}</span>} </div>
{/* Controls */} <div className="flex flex-wrap gap-2 justify-center"> {isWorking && ( <button onClick={() => send("tick")} className="btn btn-primary btn-sm">Tick</button> )} {parent === "Broken" && ( <button onClick={() => send("repair")} className="btn btn-outline btn-sm">Repair</button> )} {parent === "Maintenance" && ( <button onClick={() => send("complete")} className="btn btn-outline btn-sm">Complete</button> )} {(isWorking || parent === "Broken") && ( <button onClick={() => send("maintenance")} className="btn btn-outline btn-sm">Maintenance</button> )} {isWorking && ( <button onClick={() => send("break")} className="btn btn-destructive btn-sm">Break</button> )} </div> </div> );}import { useMemo } from "react";import { TrafficLightViewNested } from "./TrafficLightViewNested";import { createPropagatingTrafficLight } from "./machine";
export function HsmTrafficLightDemo() { const machine = useMemo(createPropagatingTrafficLight, []); return <TrafficLightViewNested machine={machine} />;}import { createHSM } from "matchina/hsm";
export function createFlatTrafficLight() { return createHSM({ initial: "Working", states: { Broken: { on: { repair: "Working", maintenance: "Maintenance", }, },
// Working is a hierarchical state with light cycle substates Working: { initial: "Red", states: { Red: { on: { tick: "Green", }, }, Green: { on: { tick: "Yellow", }, }, Yellow: { on: { tick: "Red", }, }, }, // Parent-level transitions apply to all child states on: { break: "^Broken", maintenance: "^Maintenance", }, },
Maintenance: { on: { complete: "Working", }, }, }, });}import { useMachine } from "matchina/react";
interface TrafficLightViewFlatProps { machine: any;}
const glowColor: Record<string, string> = { red: "shadow-red-500/60", yellow: "shadow-yellow-400/60", green: "shadow-green-500/60",};
function Light({ color, active }: { color: "red" | "yellow" | "green"; active: boolean }) { const offColor = { red: "bg-[oklch(0.22_0.04_15)]", yellow: "bg-[oklch(0.22_0.04_85)]", green: "bg-[oklch(0.22_0.04_142)]" }[color]; const onColor = { red: "bg-red-500", yellow: "bg-yellow-400", green: "bg-green-500" }[color]; return ( <div className={`w-12 h-12 rounded-full transition-all duration-200 ${active ? `${onColor} shadow-lg ${glowColor[color]}` : offColor}`} /> );}
export function TrafficLightViewFlat({ machine }: TrafficLightViewFlatProps) { const change = useMachine(machine) as { to: { key: string; data?: any } }; const state = change.to; const send = (event: string) => machine.send(event);
const parts = state.key.split("."); const parent = parts[0]; const child = parts[1] as string | undefined; const isWorking = parent === "Working"; const lightColor = child ?? "off";
return ( <div className="flex flex-col items-center gap-5"> {/* Housing */} <div className="flex flex-col items-center gap-3 bg-[oklch(0.18_0.01_240)] rounded-2xl px-5 py-6 border border-[oklch(0.25_0.01_240)]"> <Light color="red" active={isWorking && lightColor === "Red"} /> <Light color="yellow" active={isWorking && lightColor === "Yellow"} /> <Light color="green" active={isWorking && lightColor === "Green"} /> </div>
{/* State badges */} <div className="flex gap-2 flex-wrap justify-center"> <span className="badge badge-outline text-[10px] font-mono">{parent}</span> {child && <span className="badge badge-outline text-[10px] font-mono">{child}</span>} </div>
{/* Controls */} <div className="flex flex-wrap gap-2 justify-center"> {isWorking && ( <button onClick={() => send("tick")} className="btn btn-primary btn-sm">Tick</button> )} {parent === "Broken" && ( <button onClick={() => send("repair")} className="btn btn-outline btn-sm">Repair</button> )} {parent === "Maintenance" && ( <button onClick={() => send("complete")} className="btn btn-outline btn-sm">Complete</button> )} {(isWorking || parent === "Broken") && ( <button onClick={() => send("maintenance")} className="btn btn-outline btn-sm">Maintenance</button> )} {isWorking && ( <button onClick={() => send("break")} className="btn btn-destructive btn-sm">Break</button> )} </div> </div> );}import { useMemo } from "react";import { TrafficLightViewNested } from "./TrafficLightViewNested";import { createPropagatingTrafficLight } from "./machine";
export function HsmTrafficLightDemo() { const machine = useMemo(createPropagatingTrafficLight, []); return <TrafficLightViewNested machine={machine} />;}Nested vs flattened
Section titled “Nested vs flattened”In the nested variant, child states like Pedestrian.Walk and Pedestrian.Flashing automatically inherit the reset transition defined on their parent. You define shared behavior once on the parent, and it propagates down.
In the flattened variant, every state is a peer at the top level. Each state that needs to respond to reset must declare that transition explicitly. The machine is more verbose but immediately readable — no implicit inheritance to reason about.
Neither is universally better. Use nesting when you have genuine shared transitions across several child states. Use flat machines when clarity and explicitness matter more than concision.
Next steps
Section titled “Next steps”- Hierarchical Combobox — nested states applied to a keyboard-navigable UI widget
- Hierarchical Checkout — a submachine nested inside a larger flow
- Hierarchical Machines guide — full reference