Guide

Data & Facets

Place each fact on the state that owns its validity and lifetime.

A state can own data as well as behavior. Motive calls the data contributed by an active state its facet. The active facets together describe the facts that exist in the current configuration.

Facet ownership is a modeling decision. Put a fact on the narrowest state for which it remains true: instance-wide identity belongs near the root; phase-specific working data belongs to the phase that creates and consumes it.

Data follows the topology.

A facet is the data owned by one active state. It begins when that state enters, remains available while the state is active, and disappears when the state exits.

1 · Own the fact

Put each fact where it is true.

PackingList owns the order id because it remains true for the whole instance. Collecting owns the items because they exist only while that state is active.

import { State, Statechart, States } from "@motive/motive";
import * as Effect from "effect/Effect";
import { Schema } from "effect";

export class PackingList extends State.Compound<PackingList>()("PackingList", {
  orderId: Schema.String.pipe(
    Schema.withConstructorDefault(Effect.succeed("draft-order")),
  ),
}) {
  static states = States.make(() => [Collecting, Ready]);
}

export class Collecting extends State.Atomic<Collecting>()("Collecting", {
  items: Schema.Array(Schema.String).pipe(
    Schema.withConstructorDefault(Effect.succeed(["label", "seal"])),
  ),
}) {}

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

export const PackingListChart = Statechart.make(PackingList);

2 · Leave the owner

Exit removes the facet.

FinishPacking exits Collecting and enters Ready. The child and its items leave together; the root orderId remains because PackingList is still active.

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

const FinishPacking = Schema.TaggedStruct("FinishPacking", {});

export class PackingList extends State.Compound<PackingList>()("PackingList", {
  orderId: Schema.String.pipe(
    Schema.withConstructorDefault(Effect.succeed("draft-order")),
  ),
}) {
  static states = States.make(() => [Collecting, Ready]);
}

export class Collecting extends State.Atomic<Collecting>()("Collecting", {
  items: Schema.Array(Schema.String).pipe(
    Schema.withConstructorDefault(Effect.succeed(["label", "seal"])),
  ),
}) {
  static transitions = Transitions.make(this, ({ on }) => [
    on(FinishPacking, Ready),
  ]);
}

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

export const PackingListChart = Statechart.make(PackingList);

3 · Seed the initial path

Birth provides every required fact.

The final chart decodes its birth input, then init distributes those values to the states that own them. Every required facet in the initial configuration receives a seed.

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

const FinishPacking = Schema.TaggedStruct("FinishPacking", {});

const PackingListInput = Schema.Struct({
  orderId: Schema.String,
  initialItems: Schema.Array(Schema.String),
});

export class PackingList extends State.Compound<PackingList>()("PackingList", {
  orderId: Schema.String,
}) {
  static states = States.make(() => [Collecting, Ready]);
}

export class Collecting extends State.Atomic<Collecting>()("Collecting", {
  items: Schema.Array(Schema.String),
}) {
  static transitions = Transitions.make(this, ({ on }) => [
    on(FinishPacking, Ready),
  ]);
}

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

export const PackingListChart = Statechart.make(PackingList, {
  input: PackingListInput,
  init: ({ input }) => [
    new PackingList({ orderId: input.orderId }),
    new Collecting({ items: input.initialItems }),
  ],
});

State owns the lifetime

PackingList owns orderId, so that value exists throughout the chart instance. Its initial child Collecting owns items, so the list exists only while packing is in progress. Because both states are active in the first frame, both facets are available at once.

The two fields are not merged into an anonymous context object. Each remains attached to the state whose lifetime makes it valid. A query can therefore ask for PackingList, Collecting, or both and receive only facts justified by the active topology.

The first two frames use constructor defaults so the example can isolate this lifetime rule. A default is appropriate only when the domain really has one; it should not invent required input.

Exit removes the facet

In the second frame, send FinishPacking. The transition exits Collecting and enters Ready. Collecting.items becomes absent immediately, while PackingList.orderId remains present because the root never exited.

This is what makes facets safer than a chart-wide bag of mutable data: state exit removes facts that are no longer valid. A later state cannot accidentally read stale items merely because an earlier phase once produced them.

Initialization covers the active path

The final frame removes the invented defaults. PackingListInput decodes the value supplied when the instance starts, and init assigns each field to its owner. PackingList receives orderId; the initial child Collecting receives initialItems as its items facet.

Initialization must cover every required data-bearing state in the initial configuration. Omitting either State instance is a model error, not an undefined value discovered later at runtime.

Facets are not a global store

Moving every field to the root would keep it available, but it would erase useful information: which phase owns the fact, when the fact becomes valid, and when it must disappear. Choose a wider owner only when the domain says the value outlives the narrower state.

The next step is reading these state-owned views without weakening that geometry. Queries preserve the distinction between an active facet, an inactive state, and a derived answer.

Test the ownership

  1. Add a required warehouse: Schema.String field to Collecting, then seed it from chart input.
  2. Add customerId to PackingList and confirm that the root seed—not the child seed—must provide it.
  3. Remove the Collecting State instance and read the compile error as initial-configuration coverage.