Light Bulb Example

A light switch is one of the simplest state machines you interact with daily. Press it once, the light turns on. Press it again, the light turns off. The bulb is always in exactly one state—never both on and off, never in some undefined middle ground.

This example demonstrates the fundamental concepts of finite state machines through a digital light bulb. You’ll see how to define states, configure transitions, and implement behavior using the StateWalker orchestrator with reactive models, proper TypeScript types, fragment organization, and strict unidirectional data flow.

Try It Now

Click the button below to see the light bulb toggle between states. Open your browser console to see state transitions logged in real-time.

All code shown in this guide is fully functional and organized into working files you can explore:

light-bulb/
├── light-bulb.core/          # Core fragment (platform-independent)
│   ├── process.ts            # FSM configuration
│   ├── models.ts             # Reactive model + adapters
│   ├── controllers.ts        # State controllers
│   ├── triggers.ts           # Event triggers
│   └── index.ts              # Entry point
├── light-bulb.views/         # View fragment (browser-specific)
│   ├── render-light-bulb.ts  # UI rendering
│   ├── views.ts              # View handlers
│   └── index.ts              # Entry point
├── shared/                   # Shared utilities
│   ├── base-class.ts         # Observable base class
│   ├── adapters.ts           # Adapter pattern
│   └── registry.ts           # Cleanup registry
└── index.html                # Browser demo

The State Machine

Think about what happens when you flip a light switch. The bulb transitions from one state to another based on a single event—the flip. When the bulb is off and you flip the switch, it turns on. When it’s on and you flip again, it turns back off.

Here’s the complete FSM definition:

export const config = {
  key: "LightBulb",
  transitions: [
    ["", "*", "Off"],
    ["Off", "toggle", "On"],
    ["On", "toggle", "Off"],
    ["*", "stop", ""]
  ],
  states: [
    {
      key: "Off",
      description: "Light is off",
      events: ["toggle", "stop"]
    },
    {
      key: "On",
      description: "Light is on",
      events: ["toggle", "stop"]
    }
  ]
};

The machine starts in the Off state through the initial transition ["", "*", "Off"]. From there, a toggle event switches it to On. Another toggle event switches it back to Off. The same event produces different outcomes depending on the current state—this is the essence of state-dependent behavior.

Fragment Architecture

StateWalker applications organize code into fragments. Each example uses at least two fragments:

Core Fragment: Contains FSM config, models, adapters, controllers, and triggers. Has no dependencies on browser or DOM. Can run anywhere (Node.js, Deno, browser).

View Fragment: Contains views that transform user input into model changes and display model state. Depends on DOM/browser APIs.

This separation ensures business logic remains testable and portable across environments.

Core Fragment

The core fragment contains all business logic with no DOM dependencies.

FSM Configuration

The FSM configuration lives in light-bulb.core/process.ts:

// light-bulb.core/process.ts
export const name = "example:light-bulb";

export const config = {
  key: "LightBulb",
  transitions: [
    ["", "*", "Off"],
    ["Off", "toggle", "On"],
    ["On", "toggle", "Off"],
    ["*", "stop", ""]
  ],
  states: [
    {
      key: "Off",
      description: "Light is off",
      events: ["toggle", "stop"]
    },
    {
      key: "On",
      description: "Light is on",
      events: ["toggle", "stop"]
    }
  ]
};

Models

Models are reactive classes that manage application state and notify subscribers when data changes. The model is defined in light-bulb.core/models.ts:

// light-bulb.core/models.ts
import { newAdapter } from "../shared/adapters.js";
import { BaseClass } from "../shared/base-class.js";

export class LightBulbModel extends BaseClass {
  #count: number = 0;
  #toggleCounter: number = 0;

  get count() { return this.#count; }
  get toggleCounter() { return this.#toggleCounter; }

  // Custom subscription that fires only when toggleCounter changes
  onToggleUpdate = (callback: () => void) => {
    let counter = this.#toggleCounter;
    return this.onUpdate(() => {
      if (this.#toggleCounter !== counter) {
        counter = this.#toggleCounter;
        callback();
      }
    });
  };

  increment() {
    this.#count++;
    this.notify();
  }

  requestToggle() {
    this.#toggleCounter++;
    this.notify();
  }
}

The model uses private fields with getter methods for read access. The count field tracks total toggles. The toggleCounter field increments each time a toggle is requested, bridging between views (which write user input) and triggers (which read model state).

The onToggleUpdate() method provides an optimized subscription that only fires when toggleCounter changes. This prevents unnecessary trigger invocations when other model properties might change.

Adapters

Adapters provide type-safe access to models stored in the context. They support lazy initialization and hierarchical context lookup. Still in light-bulb.core/models.ts:

// light-bulb.core/models.ts (continued)
export const [getLightBulbModel, setLightBulbModel] =
  newAdapter<LightBulbModel>("model:lightBulb", () => new LightBulbModel());

The adapter creates the model automatically on first access, ensuring handlers always have a valid model instance to work with.

Controllers

Controllers implement business logic by reading and writing models. They always return cleanup functions and use proper TypeScript signatures. Defined in light-bulb.core/controllers.ts:

// light-bulb.core/controllers.ts
import { getLightBulbModel } from "./models.js";

export async function OnController(
  context: Record<string, unknown>
): Promise<() => void> {
  const model = getLightBulbModel(context);

  model.increment();
  console.log(`Light is ON (toggled ${model.count} times)`);

  return () => {
    // Cleanup if needed
  };
}

export async function OffController(
  context: Record<string, unknown>
): Promise<() => void> {
  const model = getLightBulbModel(context);
  console.log(`Light is OFF (toggled ${model.count} times total)`);

  return () => {
    // Cleanup if needed
  };
}

Each controller receives context as Record<string, unknown>, accesses the model through the adapter, performs its logic, and returns a cleanup function. The model updates trigger notifications to all subscribers automatically.

Triggers

Triggers observe models (read-only) and generate FSM events. They never access DOM or listen to DOM events. This is a critical architectural rule. Defined in light-bulb.core/triggers.ts:

// light-bulb.core/triggers.ts
import { getLightBulbModel } from "./models.js";

export async function* LightBulbTrigger(
  context: Record<string, unknown>
): AsyncGenerator<string> {
  const model = getLightBulbModel(context);

  // Helper to wait for next model update
  async function listenUpdates(
    onUpdate: (listener: () => void) => () => void
  ): Promise<void> {
    return new Promise<void>((resolve) => {
      const cleanup = onUpdate(() => {
        resolve();
        cleanup();
      });
    });
  }

  // Wait for toggleCounter changes and yield "toggle" events
  while (true) {
    await listenUpdates(model.onToggleUpdate);
    yield "toggle";
  }
}

The trigger returns AsyncGenerator<string> and uses a helper function listenUpdates to wait for model updates. It subscribes to the model’s onToggleUpdate method which only fires when toggleCounter changes. Each time the counter increments, the trigger yields a "toggle" FSM event. This pattern creates a continuous loop that transforms model state changes into FSM events.

View Fragment

Views handle presentation and transform user input into model changes. They have two responsibilities: capture user input and display model state. Views are defined in light-bulb.views/views.ts:

// light-bulb.views/views.ts
import { getLightBulbModel } from "../light-bulb.core/models.js";
import { newAdapter } from "../shared/adapters.js";
import { newRegistry } from "../shared/registry.js";
import { renderLightBulb } from "./render-light-bulb.js";

// Adapters for managing DOM element references
const [getLightBulbButton, setLightBulbButton, removeLightBulbButton] =
  newAdapter<HTMLElement>("html:light-bulb-button", () => {
    throw new Error("Set the button using setLightBulbButton");
  });

const [getLightBulbContainer, setLightBulbContainer, removeLightBulbContainer] =
  newAdapter<HTMLElement>("html:light-bulb-container", () => {
    throw new Error("Set the container using setLightBulbContainer");
  });

export function LightBulbView(context: Record<string, unknown>): () => void {
  const [register, cleanup] = newRegistry();
  const model = getLightBulbModel(context);

  // Render the light bulb element and add it to the DOM
  const root = document.querySelector("#light-bulb-example") as HTMLElement;
  const lightBulbElement = renderLightBulb();
  root.appendChild(lightBulbElement);
  register(() => lightBulbElement.remove());

  // Store DOM element references in context using adapters
  const container = lightBulbElement.querySelector(".light-bulb-container") as HTMLElement;
  setLightBulbContainer(context, container);
  register(() => removeLightBulbContainer(context));

  const button = lightBulbElement.querySelector(".light-bulb-switch") as HTMLButtonElement;
  setLightBulbButton(context, button);
  register(() => removeLightBulbButton(context));

  // Capture user input and write to model
  const handleClick = () => model.requestToggle();
  button.addEventListener("click", handleClick);
  register(() => button.removeEventListener("click", handleClick));

  return cleanup;
}

export function OnView(context: Record<string, unknown>): () => void {
  const [register, cleanup] = newRegistry();
  const model = getLightBulbModel(context);

  const bulbElement = getLightBulbContainer(context);
  const button = getLightBulbButton(context);

  const prevText = button.innerText;
  const updateView = () => {
    bulbElement.classList.add("on");
    bulbElement.classList.remove("off");
    button.innerText = "Turn the Light Off";
  };

  // Restore on exit
  register(() => {
    bulbElement.classList.remove("on");
    button.innerText = prevText;
  });

  updateView(); // Initial render
  register(model.onUpdate(updateView)); // Subscribe to model changes

  return cleanup;
}

export function OffView(context: Record<string, unknown>): () => void {
  const [register, cleanup] = newRegistry();
  const model = getLightBulbModel(context);

  const bulbElement = getLightBulbContainer(context);
  const button = getLightBulbButton(context);

  const prevText = button.innerText;
  const updateView = () => {
    bulbElement.classList.add("off");
    bulbElement.classList.remove("on");
    button.innerText = "Turn the Light On";
  };

  // Restore on exit
  register(() => {
    bulbElement.classList.remove("off");
    button.innerText = prevText;
  });

  updateView(); // Initial render
  register(model.onUpdate(updateView)); // Subscribe to model changes

  return cleanup;
}

The LightBulbView runs once at application startup. It creates the light bulb UI using renderLightBulb() and attaches it to the DOM. It stores DOM element references in context using adapters, making them available to state-specific views. It also sets up the button click handler that writes to the model via model.requestToggle().

State-specific views (OnView, OffView) retrieve DOM references from context using adapters, update the visual state, and subscribe to model changes for reactive updates. The registry pattern collects all cleanup functions and returns a single cleanup that handles all subscriptions and DOM cleanup.

Running the Application

In the Browser

The complete working example is available in index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Light Bulb - Statewalker FSM Example</title>
  <!-- styles omitted for brevity -->
</head>
<body>
  <h1>Light Bulb FSM Example</h1>

  <div class="info">
    <p>Click the button to toggle the light bulb on and off.</p>
    <p>Open the browser console to see state transitions.</p>
  </div>

  <script type="module">
    import { startProcesses } from "https://unpkg.com/@statewalker/fsm";
    import * as LightBulbCore from "./light-bulb.core/index.js";
    import * as LightBulbViews from "./light-bulb.views/index.js";

    startProcesses({
      modules: [LightBulbCore, LightBulbViews]
    });
  </script>
</body>
</html>

On the Server

You can run the same FSM logic on the server without the views:

npx @statewalker/fsm ./light-bulb.core/index.js

Or programmatically:

import { startProcesses } from "@statewalker/fsm";
import * as LightBulbCore from "./light-bulb.core/index.js";

startProcesses({
  modules: [LightBulbCore]
});

This works in Node.js, Bun, or Deno. The same core logic runs everywhere—only the presentation layer changes.

Understanding the Data Flow

The orchestrator follows strict unidirectional data flow. This is the complete cycle:

User clicks button → View writes to model → Model notifies → Trigger reads model → Trigger generates FSM event → State transition → Controller updates model → Model notifies → View updates display

When the application starts:

  1. The FSM initializes and enters the Off state via the initial transition
  2. The LightBulbView runs, creating the UI and setting up the button click handler
  3. The OffController runs, logging the initial “OFF” state
  4. The OffView activates, setting up the “off” visual state and subscribing to model changes
  5. The LightBulbTrigger activates, subscribing to toggleCounter changes via model.onToggleUpdate

When you click the button:

  1. Browser fires click event
  2. View’s event handler captures the click
  3. View calls model.requestToggle() to write user input to model
  4. Model increments toggleCounter and calls notify()
  5. All subscribers receive notification (including trigger)
  6. Trigger’s onToggleUpdate subscription fires (detecting toggleCounter change)
  7. Trigger’s promise resolves, exiting the await listenUpdates call
  8. Trigger yields “toggle” FSM event
  9. FSM processes the “toggle” event
  10. FSM finds matching transition: ["Off", "toggle", "On"]
  11. FSM exits the Off state (cleanup functions run, including OffView cleanup)
  12. FSM enters the On state
  13. OnController runs, calling model.increment() to increment the count
  14. Model notifies subscribers
  15. OnView activates, setting up the “on” visual state and subscribing to model changes
  16. OnView updates the DOM to show the “on” state (button text, CSS classes)
  17. Trigger loops back, waiting for next toggleCounter change

The cycle continues with each click. The FSM ensures the bulb is always in exactly one state, never both, never neither.

Key Architectural Rules

Triggers are read-only observers. Triggers never modify models. They only read model state and generate FSM events based on what they observe. This ensures triggers remain pure observers with no side effects.

Triggers never access DOM. Triggers have no dependency on browser APIs or DOM. They only observe models. This makes triggers testable and portable across any JavaScript environment.

Views bridge DOM and models. Views are the only components that access DOM. They capture user input (clicks, form submissions) and translate these into model method calls. They also read model state and update the DOM accordingly.

Models manage all state. Application state lives in reactive models, never in closures or global variables. Models notify subscribers when state changes, enabling automatic updates throughout the application.

Adapters provide type safety. Context access goes through typed adapters that support lazy initialization and hierarchical lookup, eliminating casting and manual type checks.

Unidirectional flow ensures predictability. Data flows in one direction through the application, making it easy to trace how changes propagate and debug issues.

Fragment organization ensures portability. Core fragment has no DOM dependencies and runs anywhere. View fragment has DOM dependencies and only runs in browsers. This separation makes business logic independently testable.

Next Steps

This light bulb demonstrates FSM fundamentals with minimal complexity while following the complete orchestrator architecture. Real applications involve more states, more complex transitions, and hierarchical state structures.

Explore more examples to see how these patterns scale:

Or dive deeper into the concepts: