A child chart is an independently authored statechart mounted inside another chart's active topology. Use one when the nested behavior has states and transitions that matter as a model, not merely as implementation detail.
Mount a modeled lifecycle, not an opaque operation.
An Activity keeps one operation opaque. When the behavior has independently meaningful states and transitions, a child chart gives that topology a parent-owned lifetime and an explicit boundary.
1 · Use an activity for one operation
The parent owns one opaque check.
Verifying mounts CheckRecord directly. That is enough while the work has only one chart-visible outcome and no independently meaningful lifecycle.
import {
Activity,
State,
Statechart,
States,
Transitions,
} from "@motive/motive";
import { Schema } from "effect";
const BeginReview = Schema.TaggedStruct("BeginReview", {});
export const CheckRecord = Activity.make("CheckRecord", {
success: Schema.Void,
});
export class Onboarding extends State.Compound<Onboarding>()("Onboarding") {
static states = States.make(() => [Reviewing, Verifying, Approved]);
}
class Reviewing extends State.Atomic<Reviewing>()("Reviewing") {
static transitions = Transitions.make(this, ({ on }) => [
on(BeginReview, Verifying),
]);
}
class Verifying extends State.Atomic<Verifying>()("Verifying") {
static transitions = Transitions.make(this, ({ invoke, on }) => [
invoke(CheckRecord),
on(CheckRecord.Done, Approved),
]);
}
class Approved extends State.Atomic<Approved>()("Approved") {}
export const OnboardingChart = Statechart.make(Onboarding);
2 · Promote a lifecycle into a child chart
spawn mounts one owned child instance.
IdentityCheck now owns Checking and Verified. Verification is one named occurrence mounted exactly while the parent's Verifying state remains active.
import {
Activity,
State,
Statechart,
States,
Transitions,
} from "@motive/motive";
import { Schema } from "effect";
const BeginReview = Schema.TaggedStruct("BeginReview", {});
export const CheckRecord = Activity.make("CheckRecord", {
success: Schema.Void,
});
class IdentityCheck extends State.Compound<IdentityCheck>()("IdentityCheck") {
static states = States.make(() => [Checking, Verified]);
}
class Checking extends State.Atomic<Checking>()("Checking") {
static transitions = Transitions.make(this, ({ invoke, on }) => [
invoke(CheckRecord),
on(CheckRecord.Done, Verified),
]);
}
class Verified extends State.Done<Verified>()("Verified") {}
export const IdentityCheckChart = Statechart.make(IdentityCheck);
const Verification = IdentityCheckChart.as("Verification");
export class Onboarding extends State.Compound<Onboarding>()("Onboarding") {
static states = States.make(() => [Reviewing, Verifying, Approved]);
}
class Reviewing extends State.Atomic<Reviewing>()("Reviewing") {
static transitions = Transitions.make(this, ({ on }) => [
on(BeginReview, Verifying),
]);
}
class Verifying extends State.Atomic<Verifying>()("Verifying") {
static transitions = Transitions.make(this, ({ on, spawn }) => [
spawn(Verification),
on(Verification.Done),
]);
}
class Approved extends State.Atomic<Approved>()("Approved") {}
export const OnboardingChart = Statechart.make(Onboarding);
3 · Consume the child's typed outcome
Child completion advances the parent.
Verification.Done reports the child chart's final result to its parent. Giving that channel a target moves Onboarding to Approved and retires the mounted child with its owner.
import {
Activity,
State,
Statechart,
States,
Transitions,
} from "@motive/motive";
import { Schema } from "effect";
const BeginReview = Schema.TaggedStruct("BeginReview", {});
export const CheckRecord = Activity.make("CheckRecord", {
success: Schema.Void,
});
class IdentityCheck extends State.Compound<IdentityCheck>()("IdentityCheck") {
static states = States.make(() => [Checking, Verified]);
}
class Checking extends State.Atomic<Checking>()("Checking") {
static transitions = Transitions.make(this, ({ invoke, on }) => [
invoke(CheckRecord),
on(CheckRecord.Done, Verified),
]);
}
class Verified extends State.Done<Verified>()("Verified") {}
export const IdentityCheckChart = Statechart.make(IdentityCheck);
const Verification = IdentityCheckChart.as("Verification");
export class Onboarding extends State.Compound<Onboarding>()("Onboarding") {
static states = States.make(() => [Reviewing, Verifying, Approved]);
}
class Reviewing extends State.Atomic<Reviewing>()("Reviewing") {
static transitions = Transitions.make(this, ({ on }) => [
on(BeginReview, Verifying),
]);
}
class Verifying extends State.Atomic<Verifying>()("Verifying") {
static transitions = Transitions.make(this, ({ on, spawn }) => [
spawn(Verification),
on(Verification.Done, Approved),
]);
}
class Approved extends State.Atomic<Approved>()("Approved") {}
export const OnboardingChart = Statechart.make(Onboarding);
Keep one operation as an Activity
An Activity is the smaller boundary when the parent needs to start one effectful operation and interpret its typed outcome:
invoke(CheckRecord);
on(CheckRecord.Done, Approved);
The Activity implementation may contain many internal steps, but those steps do not become
chart-visible topology. Onboarding owns the operation and only models what its outcome means.
Do not introduce a child chart simply because the implementation is substantial. Introduce one when its lifecycle has domain structure that should be authored, inspected, tested, or reused as a statechart in its own right.
Promote behavior whose topology matters
Identity checking grows into IdentityCheck, with Checking and final Verified states:
class IdentityCheck extends State.Compound<IdentityCheck>()("IdentityCheck") {
static states = States.make(() => [Checking, Verified]);
}
export const IdentityCheckChart = Statechart.make(IdentityCheck);
The child owns what it means to be checking and verified. The parent no longer impersonates those states or encodes their order as a private Activity implementation.
This is the architectural reason for a child chart: its topology is meaningful independently, even though its running lifetime will belong to a parent.
one declares a child occurrence
The chart value is a reusable definition. Derive an authored occurrence before placing it:
const Verification = IdentityCheckChart.as("Verification");
Verification identifies one fixed site for the IdentityCheckChart contract. It is not yet a
running child instance. The same child chart may be placed elsewhere under a different occurrence
identity without creating another definition.
The chart's default occurrence can be spawned directly when only one site is needed. Naming the site makes ownership explicit here and prepares the model for repeated child contracts.
spawn gives the child an owner
spawn mounts the occurrence on the state that declares it:
class Verifying extends State.Atomic<Verifying>()("Verifying") {
static transitions = Transitions.make(this, ({ on, spawn }) => [
spawn(Verification),
on(Verification.Done),
]);
}
Entering Verifying starts one Verification child instance. The child remains mounted exactly
while that owner is active. Leaving Verifying retires the live child incarnation, so late work
cannot report into a later visit as though it still belonged there.
The mount is topology, not an imperative launch call. Entry creates the child; owner exit ends its lifetime.
Parent and child keep separate topology
The parent's configuration contains Verifying. The child has its own configuration beneath that
mount, beginning at Checking and eventually reaching Verified.
The parent does not flatten the child's active states into its own state path, and the child does not reach upward to select a parent transition. Inspection can therefore answer both questions without confusing them: where is onboarding, and where is this verification instance?
Each running child is addressed through its parent and authored occurrence. That preserves independent instance identity even when many parents run the same reusable child chart.
Completion crosses the chart boundary
When IdentityCheck reaches its root State.Done, the mounted occurrence reports
Verification.Done to the parent. The second frame consumes that channel targetlessly, making the
child complete while deliberately leaving Onboarding in Verifying.
The third frame gives the same channel a parent target:
on(Verification.Done, Approved);
The child decides that its own lifecycle is complete. The parent decides what that completion means
in parent topology. The dedicated Child.Done page will unpack result values and total disposition
in detail.
Both charts join the runtime
The runtime provides implementations for every chart in the closed composition:
const OnboardingLive = Layer.mergeAll(
IdentityCheckChart.toLayer({
activities: { CheckRecord: checkRecord },
}),
OnboardingChart.toLayer(),
).pipe(Layer.provideMerge(StatechartEngine.layerMemory));
The parent layer registers the composition; the child layer supplies the child's own runtime
requirements. A client still starts and addresses the root OnboardingChart. Child instances are
created by committed parent topology rather than by constructing an unrelated client.
Choose the boundary deliberately
Prefer an Activity when internal stages are operational detail and the parent only needs its typed channels. Prefer a child chart when the nested behavior needs independently meaningful topology, protocol, data, lifecycle, reuse, or inspection.
The question is not how much code the operation contains. The question is whether its intermediate situations belong in the modeled language of the system.
Test child ownership
- Run the Activity frame and verify that only parent topology is visible.
- Run the child frame and inspect
Checkingbeneath theVerificationmount. - Leave
Verification.Donetargetless and verify that child completion does not implicitly move the parent. - Move
spawn(Verification)to theOnboardingroot and identify how that changes the child's owner and lifetime.