Quick Start
Let’s build a light bulb application that toggles between “On” and “Off” states. The goal of this example is not to create the simplest possible toggle implementation, but to demonstrate a minimal working example containing all key components of the Statewalker orchestrator architecture: state machine configuration, models with reactive updates, controllers for business logic, triggers for event generation, views for DOM interaction, and shared utilities following established patterns.
This guide gets you up and running in minutes with the essential code you need.
Project Structure
Here’s the target file layout we’ll build in this guide. The example uses a modular structure for better organization:
./quick-start/
├── light-bulb.core/ # Core fragment (platform-independent)
│ ├── index.ts # Entry point: exports handlers, name, config
│ ├── process.ts # FSM configuration (name, config)
│ ├── models.ts # LightBulbModel + adapters
│ ├── controllers.ts # OnController, OffController
│ └── triggers.ts # LightBulbTrigger
│
├── light-bulb.views/ # View fragment (browser-specific)
│ ├── index.ts # Entry point: exports name and views
│ ├── views.ts # LightBulbView, OnView, OffView
│ └── render-light-bulb.ts # HTML rendering function
│
├── shared/ # Shared utilities (minimal implementations)
│ ├── base-class.ts # Observable model base class
│ ├── adapters.ts # Adapter pattern implementation
│ └── registry.ts # Cleanup registration utility
│
├── example.html # Complete browser example with styling
└── index.md # This documentation
Core fragment structure:
process.ts- FSM process configurationmodels.ts- Reactive model class and adapterscontrollers.ts- State controllers implementing business logictriggers.ts- Event triggers observing model changesindex.ts- Entry point aggregating and exporting handlers
View fragment structure:
views.ts- View handlers for DOM interactionrender-light-bulb.ts- UI rendering functionindex.ts- Entry point exporting process name and views
Data flow:
Each element plays a specific role in the orchestrator architecture:
-
config - Declares the FSM structure with states (
Off,On), transitions, and events (toggle,stop). This blueprint defines valid state transitions and available events in each state. -
name - The process identifier
"app:light-bulb"used by the orchestrator to match fragments together. Both core and views export the samenamevalue so they’re coordinated as parts of the same process. -
models -
LightBulbModelmanages the internal state:count(total toggles) andtoggleCounter(pending toggle requests). The model notifies subscribers when data changes, enabling reactive updates throughout the architecture. Models are accessed via adapters (getLightBulbModel,setLightBulbModel) for type safety. -
controllers -
OnControllerandOffControllerexecute business logic when entering their respective states. They read and write theLightBulbModel, performing operations like incrementing the toggle count and logging state transitions. -
triggers -
LightBulbTriggerobserves theLightBulbModel(read-only) and generates"toggle"FSM events when thetoggleCounterchanges. Triggers never modify models or access the DOM—they only watch for changes and translate them into events that drive state transitions. -
views -
LightBulbViewcreates the main light bulb UI and captures user button clicks, writing toggle requests to the model viamodel.requestToggle().OnViewandOffViewsubscribe to model changes and update the visualization by adding/removing CSS classes and changing button text. Views are the only components that interact with the DOM. -
adapters - Functions like
getLightBulbModel,setLightBulbButton, andsetLightBulbContainerprovide type-safe, context-based access to models and DOM elements. Adapters support hierarchical context traversal and lazy initialization.
The data flows in one direction: User clicks button → LightBulbView writes to model → Model notifies → LightBulbTrigger reads change → Generates “toggle” event → FSM transitions state → OnController/OffController updates model → Model notifies → OnView/OffView updates DOM.
The State Machine
Define the states and transitions in your process configuration:
// light-bulb.core/process.ts
export const name = "app:light-bulb";
export const config = {
key: "LightBulb",
transitions: [
["", "*", "Off"], // Start in Off state
["Off", "toggle", "On"], // Toggle turns light on
["On", "toggle", "Off"], // Toggle turns light off
["*", "stop", ""] // Exit from any state
],
states: [
{
key: "Off",
description: "Light is off",
events: ["toggle", "stop"]
},
{
key: "On",
description: "Light is on",
events: ["toggle", "stop"]
}
]
};
Core Fragment
The core fragment contains models, adapters, controllers, and triggers. This fragment has no dependency on the browser or DOM.
Models
Models manage application state and notify subscribers when data changes:
// light-bulb.core/models.ts
import { BaseClass } from "../shared/base-class.js";
import { newAdapter } from "../shared/adapters.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();
}
}
export const [getLightBulbModel, setLightBulbModel] = newAdapter<LightBulbModel>(
"model:lightBulb",
() => new LightBulbModel()
);
Controllers
Controllers implement business logic by reading and writing models:
// 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
};
}
Triggers
Triggers observe models (read-only) and generate FSM events. They never access DOM:
// 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";
}
}
Core Entry Point
The core fragment’s index.ts exports all handlers and configuration:
// light-bulb.core/index.ts
import * as Controllers from "./controllers.js";
import * as Triggers from "./triggers.js";
// Export process name and config
export * from "./process.js";
// Export all controllers and triggers as default
export default {
...Controllers,
...Triggers,
};
View Fragment
The view fragment transforms user input into model changes and displays model state:
// light-bulb.views/index.ts
export const name = "app:light-bulb";
import * as Views from "./views.js";
export default {
...Views,
};
// 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 in context
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-demo") 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");
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");
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;
}
Running the Application
In the Browser
<!DOCTYPE html>
<html>
<body>
<div id="light-bulb-demo"></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>
The light starts in the “Off” state and transitions between states through the toggle event. The view automatically creates and renders the light bulb UI.
Understanding the Data Flow
The orchestrator follows strict unidirectional flow:
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 you click the button:
- View captures the click event
- View calls
model.requestToggle()which increments thetoggleCounter - Model notifies all subscribers
- Trigger detects
toggleCounterchange viamodel.onToggleUpdate - Trigger generates the “toggle” FSM event
- FSM processes the event and transitions states
- Controller runs and increments the count
- Model notifies subscribers
- View receives notification and updates the display (button text and CSS class)
This separation ensures triggers and controllers never directly access the DOM. Views bridge between DOM events and model state. Triggers observe model state (read-only) and generate FSM events based on changes.
What You’ve Built
You’ve created a finite state machine with proper separation of concerns. The core fragment contains all business logic and can run anywhere (browser, Node.js, Deno). Controllers execute state-specific behavior using reactive models that automatically notify subscribers when data changes. Views transform user input into model updates and display model state. Triggers observe models (read-only) and generate FSM events based on model state changes. The modular fragment structure keeps concerns separated and makes each component independently testable.
This same pattern scales to complex applications with dozens of states, nested hierarchies, and sophisticated business logic.
Shared Utilities
The shared/ directory demonstrates implementation patterns rather than prescribing specific solutions. The patterns themselves—observable models, context-based adapters, cleanup registries—are the key architectural concepts. These implementations are simplified examples; production applications will adapt these patterns to their specific needs.
// shared/base-class.ts
export class BaseClass {
#listeners: Set<() => void> = new Set();
onUpdate(listener: () => void): () => void {
this.#listeners.add(listener);
return () => this.#listeners.delete(listener);
}
protected notify(): void {
this.#listeners.forEach((listener) => listener());
}
}
// shared/adapters.ts (simplified - no parent traversal)
export function newAdapter<T>(
key: string,
create?: (context: Record<string, unknown>) => T
): [
get: (context: Record<string, unknown>) => T,
set: (context: Record<string, unknown>, value: T) => void,
remove: (context: Record<string, unknown>) => void
] {
const get = (context: Record<string, unknown>): T => {
let result = (context as any)[key];
if (result === undefined && create) {
result = create(context);
(context as any)[key] = result;
}
if (result === undefined) {
throw new Error(`Adapter not found: ${key}`);
}
return result;
};
const set = (context: Record<string, unknown>, value: T): void => {
(context as any)[key] = value;
};
const remove = (context: Record<string, unknown>): void => {
delete (context as any)[key];
};
return [get, set, remove];
}
// shared/registry.ts (simplified - basic error handling)
export function newRegistry(): [
register: (callback?: () => void | Promise<void>) => () => Promise<void>,
cleanup: () => Promise<void>
] {
const callbacks: Array<() => void | Promise<void>> = [];
const register = (callback?: () => void | Promise<void>) => {
if (callback) callbacks.push(callback);
return async () => {
const index = callbacks.indexOf(callback!);
if (index > -1) {
callbacks.splice(index, 1);
await callback?.();
}
};
};
const cleanup = async () => {
for (const cb of callbacks.reverse()) {
await cb();
}
callbacks.length = 0;
};
return [register, cleanup];
}
Production adaptations might include:
- BaseClass: Serialization (
toJSON/fromJSON), change detection, computed properties - Adapters: Parent context traversal, validation, lazy initialization, scoped namespacing
- Registry: Error aggregation, logging, timeout handling, priority-based cleanup order
- Additional utilities: Async generator helpers, dependency injection, state persistence
Next Steps
See this implementation in more detail in the Light Bulb example, which includes complete trigger implementation, styling, and explanation of each concept.
Explore more examples to see how these patterns handle real-world scenarios like user authentication, form validation, and order processing.
Or dive into the concepts to understand how state machines work for your application.