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:

View fragment structure:

Data flow:

Each element plays a specific role in the orchestrator architecture:

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:

  1. View captures the click event
  2. View calls model.requestToggle() which increments the toggleCounter
  3. Model notifies all subscribers
  4. Trigger detects toggleCounter change via model.onToggleUpdate
  5. Trigger generates the “toggle” FSM event
  6. FSM processes the event and transitions states
  7. Controller runs and increments the count
  8. Model notifies subscribers
  9. 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:

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.