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
- Add a required
warehouse: Schema.Stringfield toCollecting, then seed it from chart input. - Add
customerIdtoPackingListand confirm that the root seed—not the child seed—must provide it. - Remove the
CollectingState instance and read the compile error as initial-configuration coverage.