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:
- The FSM initializes and enters the
Offstate via the initial transition - The
LightBulbViewruns, creating the UI and setting up the button click handler - The
OffControllerruns, logging the initial “OFF” state - The
OffViewactivates, setting up the “off” visual state and subscribing to model changes - The
LightBulbTriggeractivates, subscribing totoggleCounterchanges viamodel.onToggleUpdate
When you click the button:
- Browser fires click event
- View’s event handler captures the click
- View calls
model.requestToggle()to write user input to model - Model increments
toggleCounterand callsnotify() - All subscribers receive notification (including trigger)
- Trigger’s
onToggleUpdatesubscription fires (detectingtoggleCounterchange) - Trigger’s promise resolves, exiting the
await listenUpdatescall - Trigger yields “toggle” FSM event
- FSM processes the “toggle” event
- FSM finds matching transition:
["Off", "toggle", "On"] - FSM exits the
Offstate (cleanup functions run, includingOffViewcleanup) - FSM enters the
Onstate OnControllerruns, callingmodel.increment()to increment the count- Model notifies subscribers
OnViewactivates, setting up the “on” visual state and subscribing to model changesOnViewupdates the DOM to show the “on” state (button text, CSS classes)- Trigger loops back, waiting for next
toggleCounterchange
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:
- Traffic Light - Multiple states with automatic timer-based transitions
- User Authentication - Handling error cases and validation flows
- Order Processing - Complex business logic with nested states
Or dive deeper into the concepts:
- FSM Configuration Schema - Complete FSM configuration options
- Handlers and Context - Advanced handler patterns
- State Machine Concepts - Understanding FSM fundamentals