Guide

Component Definitions and Placements

Capture one reusable state tree, place it at a concrete authored path, and extend it at the host boundary.

Statecharts make topology explicit, but real systems repeat useful behavior: playback controls, approval flows, connection lifecycles, retrying jobs, and protocol sessions. A Component makes one state tree reusable without turning it into an opaque widget or a running singleton.

Reuse authored structure, not instances

Component.make gives a reusable state tree an explicit authoring boundary. Define the root and its private State vocabulary inside the callback, then return the root. Motive captures the States reachable from that root, their transitions, and the event alphabet those transitions use. It does not start an instance, allocate runtime state, or register global behavior.

The captured classes remain ordinary statechart structure. Paused still owns its Toggle transition; Component does not add a second dispatch system around the model.

A placement gives the definition a location

const Playback = Component.make(() => {
  class Playback extends State.Compound<Playback>()("Playback") {
    static states = States.make(() => [Paused, Playing]);
  }

  class Paused extends State.Atomic<Paused>()("Paused") {}
  class Playing extends State.Atomic<Playing>()("Playing") {}

  return Playback;
});

class Player extends Playback.as<Player>()("Player") {}

The shared name is deliberate. Inside the callback, Playback names the root State; outside it, Playback names the reusable Component whose topology begins at that root.

The definition says what can be placed. Player says where this occurrence lives. When MediaApp includes the placement, its topology contains Player.Paused and Player.Playing; the template name Playback does not become a hidden extra path segment.

The Self generic preserves the placement's exact static identity. Targets such as Player.Paused are checked state classes, not strings reconstructed from naming conventions.

The open form is valid when the root State is intentionally part of the surrounding module's vocabulary:

class Playback extends State.Compound<Playback>()("Playback") {
  // ...
}

const PlaybackComponent = Component.make(() => Playback);

Use that form when another authoring surface genuinely needs the unplaced Playback symbol. When the classes exist only to define the reusable Component, enclosing them makes that ownership visible in the source.

The host owns the extension surface

A placement is a real state class, so its host may add behavior at that boundary:

class Player extends Playback.as<Player>()("Player") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Reset, Player.Paused),
  ]);
}

Toggle remains definition-owned. Reset is placement-owned. The compiled chart merges both surfaces into one transition topology without copying or mutating Playback.

That merge is checked for semantic conflicts. A host cannot silently shadow an unguarded definition arm for the same event; any deliberate guarded takeover must remain explicit in the authored structure.

Follow the ordinary tree into one placed and extended occurrence:

Reusable topology

Definitions become real at a placement.

Watch one ordinary state tree become a reusable definition, then a concrete placement with its own host-owned behavior.

1 · Begin with an ordinary state tree

Playback already owns one complete behavior.

Paused and Playing form one ordinary compound state with a Toggle event. Nothing about the statechart semantics changes when this same tree later becomes reusable; Component begins by preserving the structure we already understand.

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

export class Toggle extends Schema.TaggedClass<Toggle>()("Toggle", {}) {}

export class Playback extends State.Compound<Playback>()("Playback") {
  static states = States.make(() => [Paused, Playing]);
}

class Paused extends State.Atomic<Paused>()("Paused") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Toggle, Playing),
  ]);
}

class Playing extends State.Atomic<Playing>()("Playing") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Toggle, Paused),
  ]);
}

export const PlaybackChart = Statechart.make(Playback);

2 · Capture, then place

The definition is reusable; Player is the authored location.

Component.make captures Playback and its event alphabet without mounting it. Playback.as<Player>()("Player") creates one concrete placement whose descendants become Player.Paused and Player.Playing inside MediaApp.

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

export class Toggle extends Schema.TaggedClass<Toggle>()("Toggle", {}) {}

const Playback = Component.make(() => {
  class Playback extends State.Compound<Playback>()("Playback") {
    static states = States.make(() => [Paused, Playing]);
  }

  class Paused extends State.Atomic<Paused>()("Paused") {
    static transitions = Transitions.make(this, ({ on }) => [
      on(Toggle, Playing),
    ]);
  }

  class Playing extends State.Atomic<Playing>()("Playing") {
    static transitions = Transitions.make(this, ({ on }) => [
      on(Toggle, Paused),
    ]);
  }

  return Playback;
});

export class MediaApp extends State.Compound<MediaApp>()("MediaApp") {
  static states = States.make(() => [Player]);
}

class Player extends Playback.as<Player>()("Player") {}

export const MediaAppChart = Statechart.make(MediaApp);

3 · Extend at the host boundary

A placement may add host behavior without editing the template.

Player adds one Reset arm that targets its placement-qualified Paused node. Toggle still belongs to the captured definition, while Reset belongs to this host placement; the two surfaces merge without copying or mutating Playback.

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

export class Toggle extends Schema.TaggedClass<Toggle>()("Toggle", {}) {}
export class Reset extends Schema.TaggedClass<Reset>()("Reset", {}) {}

export const Playback = Component.make(() => {
  class Playback extends State.Compound<Playback>()("Playback") {
    static states = States.make(() => [Paused, Playing]);
  }

  class Paused extends State.Atomic<Paused>()("Paused") {
    static transitions = Transitions.make(this, ({ on }) => [
      on(Toggle, Playing),
    ]);
  }

  class Playing extends State.Atomic<Playing>()("Playing") {
    static transitions = Transitions.make(this, ({ on }) => [
      on(Toggle, Paused),
    ]);
  }

  return Playback;
});

export class MediaApp extends State.Compound<MediaApp>()("MediaApp") {
  static states = States.make(() => [Player]);
}

class Player extends Playback.as<Player>()("Player") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Reset, Player.Paused),
  ]);
}

export const MediaAppChart = Statechart.make(MediaApp);

The behavior survives placement unchanged

The first frame establishes the baseline: Toggle cycles the ordinary Playback chart between Paused and Playing. Capturing the tree in the second frame changes its location, not its behavior. The same event now moves through Player.Paused and Player.Playing.

This is the key promise of Component reuse: topology stays visible and statechart semantics remain the only computational model.

Placement-owned behavior stays local

The final frame adds Reset only to Player. Toggle into Player.Playing, then reset to the placement-qualified Player.Paused. Another placement of the same Component could expose a different host extension without changing this one or the captured definition.

The placement tag is therefore part of model identity, not presentation copy. It appears in the compiled graph, qualifies state paths and occurrence references, and contributes to chart hashing.

Test the definition boundary

  1. Compile the original state tree and the placed tree, then verify their definition-owned event behavior is equivalent under the qualified path.
  2. Verify the Component definition allocates no instance or runtime service by itself.
  3. Add a placement-owned event and verify it targets the placement-qualified child.
  4. Place the same definition under two different tags and verify their paths and occurrences stay distinct.
  5. Attempt to shadow an unguarded definition arm and verify authoring rejects the conflict.
  6. Inspect the compiled topology and verify no hidden template path segment appears.

Next, Repeated Placements and Occurrence Identity places the same definition more than once and follows the identity each occurrence receives.