A transition does not have to restart every state above its target. When a compound state targets one of its own descendants, Motive preserves the compound by default and changes only the active configuration beneath it.
Move to a descendant without restarting its owner.
When a compound state targets one of its own descendants, Motive changes the active child without restarting the compound. The owner stays active by default.
1 · Begin with sibling movement
Changing the child does not restart its parent.
Overview owns the edge to its sibling Profile. Overview exits and Profile enters, but their common owner Workspace stays active and keeps its draft facet.
import { State, Statechart, States, Transitions } from "@motive/motive";
import * as Effect from "effect/Effect";
import { Schema } from "effect";
const ChangeDraft = Schema.TaggedStruct("ChangeDraft", {
draft: Schema.String,
});
const SelectProfile = Schema.TaggedStruct("SelectProfile", {});
export class Workspace extends State.Compound<Workspace>()("Workspace", {
draft: Schema.String.pipe(Schema.withConstructorDefault(Effect.succeed(""))),
}) {
static states = States.make(() => [Overview, Profile]);
}
class Overview extends State.Atomic<Overview>()("Overview") {
static transitions = Transitions.make(this, ({ on }) => [
on(ChangeDraft).update(Workspace, ({ event }) => ({ draft: event.draft })),
on(SelectProfile, Profile),
]);
}
class Profile extends State.Atomic<Profile>()("Profile") {}
export const WorkspaceChart = Statechart.make(Workspace);
2 · Move the rule to its owner
A compound can target its own descendant.
Workspace now owns SelectProfile and targets Profile below itself. Descendant transitions are internal by default: Overview exits, Profile enters, and Workspace remains active.
import { State, Statechart, States, Transitions } from "@motive/motive";
import * as Effect from "effect/Effect";
import { Schema } from "effect";
const ChangeDraft = Schema.TaggedStruct("ChangeDraft", {
draft: Schema.String,
});
const SelectProfile = Schema.TaggedStruct("SelectProfile", {});
export class Workspace extends State.Compound<Workspace>()("Workspace", {
draft: Schema.String.pipe(Schema.withConstructorDefault(Effect.succeed(""))),
}) {
static states = States.make(() => [Overview, Profile]);
static get transitions() {
return Transitions.make(this, ({ on }) => [
on(ChangeDraft).update(Workspace, ({ event }) => ({
draft: event.draft,
})),
on(SelectProfile, Profile),
]);
}
}
class Overview extends State.Atomic<Overview>()("Overview") {}
class Profile extends State.Atomic<Profile>()("Profile") {}
export const WorkspaceChart = Statechart.make(Workspace);
Child movement already preserves the parent
The first frame places SelectProfile on Overview. Its target, Profile, is a sibling. Taking
the transition exits Overview and enters Profile; their common parent Workspace stays active.
Save a draft before opening the profile. The draft belongs to Workspace, so it survives the child
change. The live readout is a lifecycle witness: the owner was never exited and reconstructed.
Put shared navigation on the shared owner
SelectProfile describes navigation within the workspace rather than behavior unique to
Overview. The second frame moves the handler to Workspace and keeps Profile as its target:
on(SelectProfile, Profile);
Now the transition source is the compound state and the target is its proper descendant. The rule
can apply from anywhere beneath Workspace where no nearer active state handles the same event.
Descendant transitions are internal by default
An ancestor-to-descendant transition preserves its source unless you explicitly request reentry.
Here, Overview still exits and Profile still enters, but Workspace appears in neither side of
that lifecycle boundary.
“Internal” describes the transition domain. It does not mean the event is hidden, raised, or otherwise different from an ordinary event sent by a client.
Preserved owners keep what they own
The draft facet survives because its state survives. The same rule applies to every citizen mounted
by Workspace: its timers, activities, children, and resource bindings keep their current
incarnations while navigation changes descendants beneath them.
This is why internal descendant movement is the default. Local navigation should not accidentally restart a longer-lived owner and everything attached to it.
The target and lifecycle boundary are different facts
Profile says where the resulting configuration should go. It does not, by itself, say that
Workspace must leave. Motive derives the smallest transition domain needed to reach the target.
Sometimes restarting the owner is exactly what the domain requires. The next page keeps this same
source and target, then uses .reenter() to request that larger lifecycle boundary explicitly.
Test the boundary
- Save a distinctive draft, then open
Profilein both frames and confirm it survives. - Move
ChangeDraftback toOverviewand decide whether its behavior or only its ownership changed. - Add another child and observe that the ancestor-owned handler remains available throughout
Workspace.