Guide

Statecharts & Instances

Use one declarative chart to govern many independently running instances.

A statechart is a declarative model of behavior. An instance is one identified execution of that model, with its own active configuration and history. One DoorChart can govern every door in a building without the front door sharing state with the back door.

One model, two independent doors

We will keep DoorChart fixed while the runtime grows from a reusable definition into two addressed instances. Then we will send the same event to only one of them.

1 · Declarative model

A statechart describes possible behavior.

DoorChart assembles states, events, and transitions into one reusable model. It describes what a door may do, but it is not yet any particular door and carries no running configuration of its own.

import { State, Statechart, States, Transitions } from "@motive/motive";
import { Schema } from "effect";

const UnlockDoor = Schema.TaggedStruct("UnlockDoor", {});
const LockDoor = Schema.TaggedStruct("LockDoor", {});
const OpenDoor = Schema.TaggedStruct("OpenDoor", {});
const CloseDoor = Schema.TaggedStruct("CloseDoor", {});

class Door extends State.Compound<Door>()("Door") {
  static states = States.make(() => [Closed, Open]);
}

class Closed extends State.Compound<Closed>()("Closed") {
  static states = States.make(() => [Locked, Unlocked]);
}

class Locked extends State.Atomic<Locked>()("Locked") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(UnlockDoor, Unlocked),
  ]);
}

class Unlocked extends State.Atomic<Unlocked>()("Unlocked") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(LockDoor, Locked),
    on(OpenDoor, Open),
  ]);
}

class Open extends State.Atomic<Open>()("Open") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(CloseDoor, Closed),
  ]);
}

export const DoorChart = Statechart.make(Door);

2 · Runtime implementation

The model remains reusable at the runtime boundary.

DoorChart.toLayer() builds its Effect runtime layer. This chart requires no activity, timer, emit, retry, or Resource implementations, so there is no placeholder configuration to pass—but the layer can still serve any number of instances.

import { DoorChart } from "../chart.ts";

export const DoorLive = DoorChart.toLayer();

3 · Client constructor

A chart provides one typed client constructor.

DoorChart.client yields makeDoor, a constructor whose commands and queries are derived from this exact chart. The constructor is still not a running door: it needs an instance identity first.

import { Effect } from "effect";
import { DoorChart } from "../chart.ts";

export const DoorLive = DoorChart.toLayer();

export const program = Effect.gen(function* () {
  const makeDoor = yield* DoorChart.client;
  return makeDoor;
});

4 · Root identity

An instance begins with an identity.

Statechart.id("front-door") names one durable coordinate. Passing it to makeDoor constructs a client addressed to that instance; it does not copy or modify DoorChart.

import { Statechart } from "@motive/motive";
import { Effect } from "effect";
import { DoorChart } from "../chart.ts";

export const DoorLive = DoorChart.toLayer();

const frontDoorId = Statechart.id("front-door");

export const program = Effect.gen(function* () {
  const makeDoor = yield* DoorChart.client;
  const frontDoor = makeDoor(frontDoorId);
  return frontDoor;
});

5 · Initial configuration

Starting realizes the model for one instance.

frontDoor.start() creates the instance if it does not exist and enters the model's initial path. Its first snapshot contains Door, Closed, and Closed.Locked; that configuration belongs to front-door alone.

import { Statechart } from "@motive/motive";
import { Effect } from "effect";
import { DoorChart } from "../chart.ts";

export const DoorLive = DoorChart.toLayer();

const frontDoorId = Statechart.id("front-door");

export const program = Effect.gen(function* () {
  const makeDoor = yield* DoorChart.client;
  const frontDoor = makeDoor(frontDoorId);

  yield* frontDoor.start();

  return yield* frontDoor.snapshot;
});

6 · Another coordinate

The same chart can address another instance.

The same makeDoor constructor accepts back-door. Both clients have the same typed commands and queries because both are governed by DoorChart; their identities determine which running history each operation addresses.

import { Statechart } from "@motive/motive";
import { Effect } from "effect";
import { DoorChart } from "../chart.ts";

export const DoorLive = DoorChart.toLayer();

const frontDoorId = Statechart.id("front-door");
const backDoorId = Statechart.id("back-door");

export const program = Effect.gen(function* () {
  const makeDoor = yield* DoorChart.client;
  const frontDoor = makeDoor(frontDoorId);
  const backDoor = makeDoor(backDoorId);

  return { frontDoor, backDoor };
});

7 · Independent execution

Each instance starts from the same model.

Starting both clients produces the same initial configuration twice, once at each root identity. They agree because they follow the same declarative model—not because they share mutable state.

import { Statechart } from "@motive/motive";
import { Effect } from "effect";
import { DoorChart } from "../chart.ts";

export const DoorLive = DoorChart.toLayer();

const frontDoorId = Statechart.id("front-door");
const backDoorId = Statechart.id("back-door");

export const program = Effect.gen(function* () {
  const makeDoor = yield* DoorChart.client;
  const frontDoor = makeDoor(frontDoorId);
  const backDoor = makeDoor(backDoorId);

  yield* frontDoor.start();
  yield* backDoor.start();

  const front = yield* frontDoor.snapshot;
  const back = yield* backDoor.snapshot;

  return { front, back };
});

8 · Independent state

Instances share rules, not state.

Sending UnlockDoor to frontDoor changes only that instance. backDoor remains locked: one DoorChart defines both sets of rules, while each root identity owns its own configuration, data, mounted work, and history.

import { NodeRuntime } from "@effect/platform-node";
import { Statechart, StatechartEngine } from "@motive/motive";
import { Effect, Layer } from "effect";
import { DoorChart } from "../chart.ts";

const DoorLive = DoorChart.toLayer();

const frontDoorId = Statechart.id("front-door");
const backDoorId = Statechart.id("back-door");

const program = Effect.gen(function* () {
  const makeDoor = yield* DoorChart.client;
  const frontDoor = makeDoor(frontDoorId);
  const backDoor = makeDoor(backDoorId);

  yield* frontDoor.start();
  yield* backDoor.start();

  yield* frontDoor.send.UnlockDoor();

  const front = yield* frontDoor.snapshot;
  const back = yield* backDoor.snapshot;

  yield* Effect.log("front-door", front.configuration);
  yield* Effect.log("back-door", back.configuration);
});

program.pipe(
  Effect.provide(
    DoorLive.pipe(Layer.provideMerge(StatechartEngine.layerMemory)),
  ),
  Effect.scoped,
  NodeRuntime.runMain,
);

The model owns the rules

DoorChart owns the states that may be active, the events it accepts, and the transitions those events may trigger. It is declarative: inspecting the chart tells us what every door instance can do without choosing which door is running or where it is now.

Changing the model changes the rules for instances created from that model. It does not create a singleton store of current state.

The instance owns the run

Each RootInstanceId selects an independent run of DoorChart. The instance owns its current configuration, state data, mounted work, and retained history. Clients address that run: starting, sending an event, or reading a snapshot through frontDoor cannot silently act on backDoor.

This separation is what lets one declarative chart govern a fleet of durable workflows, UI components, agents, or devices without conflating their lives.