Guide

Compound States

Model coactive truths, initial entry, and shared behavior with hierarchy.

Compound states contain child states. They model situations with meaningful internal structure: a lamp can be On while its brightness is Dimmed, and both facts remain active together.

Give On a topology of its own.

The flat lamp can behave correctly, but it cannot express that both brightnesses are forms of being on. Nesting makes that domain fact structural.

1 · Flat topology

Flattening hides the shared truth.

Off, Dimmed, and Bright are mutually exclusive, yet both brightness states mean the lamp is on. SwitchOff is duplicated because that shared fact has no state of its own.

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

const SwitchOn = Schema.TaggedStruct("SwitchOn", {});
const SwitchOff = Schema.TaggedStruct("SwitchOff", {});
const Brighten = Schema.TaggedStruct("Brighten", {});
const Dim = Schema.TaggedStruct("Dim", {});

class Lamp extends State.Compound<Lamp>()("Lamp") {
  static states = States.make(() => [Off, Dimmed, Bright]);
}

class Off extends State.Atomic<Off>()("Off") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(SwitchOn, Dimmed),
  ]);
}

class Dimmed extends State.Atomic<Dimmed>()("Dimmed") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Brighten, Bright),
    on(SwitchOff, Off),
  ]);
}

class Bright extends State.Atomic<Bright>()("Bright") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Dim, Dimmed),
    on(SwitchOff, Off),
  ]);
}

export const LampChart = Statechart.make(Lamp);

2 · Compound state

Compound states make truths coactive.

On becomes compound, with Dimmed and Bright inside it. The active configuration can now say that the lamp is on and dimmed at the same time.

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

const SwitchOn = Schema.TaggedStruct("SwitchOn", {});
const SwitchOff = Schema.TaggedStruct("SwitchOff", {});
const Brighten = Schema.TaggedStruct("Brighten", {});
const Dim = Schema.TaggedStruct("Dim", {});

class Lamp extends State.Compound<Lamp>()("Lamp") {
  static states = States.make(() => [Off, On]);
}

class Off extends State.Atomic<Off>()("Off") {
  static transitions = Transitions.make(this, ({ on }) => [on(SwitchOn, On)]);
}

class On extends State.Compound<On>()("On") {
  static states = States.make(() => [Dimmed, Bright]);
}

class Dimmed extends State.Atomic<Dimmed>()("Dimmed") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Brighten, Bright),
    on(SwitchOff, Off),
  ]);
}

class Bright extends State.Atomic<Bright>()("Bright") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Dim, Dimmed),
    on(SwitchOff, Off),
  ]);
}

export const LampChart = Statechart.make(Lamp);

3 · Shared ownership

Shared behavior belongs to the shared state.

Whenever either brightness is active, On is active too. One SwitchOff transition on On replaces both child copies and remains available throughout its subtree.

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

const SwitchOn = Schema.TaggedStruct("SwitchOn", {});
const SwitchOff = Schema.TaggedStruct("SwitchOff", {});
const Brighten = Schema.TaggedStruct("Brighten", {});
const Dim = Schema.TaggedStruct("Dim", {});

class Lamp extends State.Compound<Lamp>()("Lamp") {
  static states = States.make(() => [Off, On]);
}

class Off extends State.Atomic<Off>()("Off") {
  static transitions = Transitions.make(this, ({ on }) => [on(SwitchOn, On)]);
}

class On extends State.Compound<On>()("On") {
  static states = States.make(() => [Dimmed, Bright]);
  static transitions = Transitions.make(this, ({ on }) => [on(SwitchOff, Off)]);
}

class Dimmed extends State.Atomic<Dimmed>()("Dimmed") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Brighten, Bright),
  ]);
}

class Bright extends State.Atomic<Bright>()("Bright") {
  static transitions = Transitions.make(this, ({ on }) => [on(Dim, Dimmed)]);
}

export const LampChart = Statechart.make(Lamp);

A configuration is a path

Lamp, On, and Dimmed are not competing phases. They are one active path through the topology, from the broadest fact to the most specific: this lamp exists, it is on, and it is currently dimmed.

Brighten replaces the active leaf, Dimmed, with its sibling Bright. It does not exit On, so the shared fact remains active throughout the brightness change.

Entry follows an initial path

A compound state is not a place where entry can stop. Entering On must continue into one of its children so the resulting configuration ends at an atomic leaf.

Direct-child order spells that initial choice. Because Dimmed appears first beneath On, SwitchOn enters Lamp.On.Dimmed. Reversing the children would make Bright the initial brightness; the choice is authored topology, not runtime guesswork.

Behavior belongs to its nearest owner

Transition selection begins at the active leaf and may continue through its active ancestors. While either brightness child is active, the transition owned by On can accept SwitchOff.

This is more than deduplication. On is the domain fact that makes SwitchOff meaningful, so On owns the rule. Every active descendant keeps that rule available because every one of them also makes On active.

Test the boundary

  1. Reverse the child order beneath On. Predict which brightness becomes active after SwitchOn before running the chart.
  2. Add a Broken state. Decide first whether a broken lamp can still be on, then let that answer determine whether Broken belongs inside On or beside it.
  3. Add SetLowPower. If it must work from every brightness but not from Off, choose the state that should own it before writing the transition.