Guide

Internal Descendant Transitions

Move to a descendant while preserving the compound source lifecycle.

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

  1. Save a distinctive draft, then open Profile in both frames and confirm it survives.
  2. Move ChangeDraft back to Overview and decide whether its behavior or only its ownership changed.
  3. Add another child and observe that the ancestor-owned handler remains available throughout Workspace.