Guide

exit

Run an ordered action list whenever its owning state stops being active.

entry() runs when a state becomes active. exit() owns the other side of that lifecycle: an ordered action list that runs whenever the state stops being active.

Run work when the state stops being active.

entry() owns arrival work. exit() gives the same state an ordered action list for every time its active lifecycle ends.

1 · Begin with arrivals

Gallery already counts each entry.

The familiar entry action increments galleryEntries whenever Gallery becomes active. Returning to Lobby exits Gallery, but no departure action records that lifecycle boundary yet.

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

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

const countGalleryEntry = Query.gen(function* () {
  const museum = yield* Museum;
  return { galleryEntries: museum.galleryEntries + 1 };
});

export class Museum extends State.Compound<Museum>()("Museum", {
  galleryEntries: Schema.Finite.pipe(
    Schema.withConstructorDefault(Effect.succeed(0)),
  ),
  galleryExits: Schema.Finite.pipe(
    Schema.withConstructorDefault(Effect.succeed(0)),
  ),
}) {
  static states = States.make(() => [Lobby, Gallery]);
}

class Lobby extends State.Atomic<Lobby>()("Lobby") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(EnterGallery, Gallery),
  ]);
}

class Gallery extends State.Atomic<Gallery>()("Gallery") {
  static transitions = Transitions.make(this, ({ entry, on }) => [
    entry().update(Museum, countGalleryEntry),
    on(ReturnToLobby, Lobby),
  ]);
}

export const MuseumChart = Statechart.make(Museum);

2 · Give exit an action

Gallery counts each departure too.

Gallery now owns exit().update(...). Entering increments arrivals; returning increments departures. Repeat the cycle to see each action follow its own side of the state lifecycle.

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

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

const countGalleryEntry = Query.gen(function* () {
  const museum = yield* Museum;
  return { galleryEntries: museum.galleryEntries + 1 };
});

const countGalleryExit = Query.gen(function* () {
  const museum = yield* Museum;
  return { galleryExits: museum.galleryExits + 1 };
});

export class Museum extends State.Compound<Museum>()("Museum", {
  galleryEntries: Schema.Finite.pipe(
    Schema.withConstructorDefault(Effect.succeed(0)),
  ),
  galleryExits: Schema.Finite.pipe(
    Schema.withConstructorDefault(Effect.succeed(0)),
  ),
}) {
  static states = States.make(() => [Lobby, Gallery]);
}

class Lobby extends State.Atomic<Lobby>()("Lobby") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(EnterGallery, Gallery),
  ]);
}

class Gallery extends State.Atomic<Gallery>()("Gallery") {
  static transitions = Transitions.make(this, ({ entry, exit, on }) => [
    entry().update(Museum, countGalleryEntry),
    exit().update(Museum, countGalleryExit),
    on(ReturnToLobby, Lobby),
  ]);
}

export const MuseumChart = Statechart.make(Museum);

Begin with the arrival side

The first frame carries the Museum forward from the previous page. Entering Gallery increments galleryEntries; returning to Lobby changes the active state but records no departure.

The model already has a galleryExits field so the second frame can change one behavior without changing the visible data contract.

exit() belongs to the state that leaves

The second frame adds this branch to Gallery:

exit().update(Museum, countGalleryExit);

Enter the gallery and arrivals become 1 while departures remain 0. Return to the lobby and departures become 1. A second cycle produces 2 and 2: each boundary action follows its own side of each Gallery lifecycle.

Departure work is not tied to one route

ReturnToLobby is the only route out of Gallery in this small model, but the exit action does not belong to that event edge. Add another transition out and the same state-owned departure work runs for that route too.

This keeps cleanup, final bookkeeping, and other departure rules attached to the lifetime they describe instead of copying them across every possible outgoing transition.

The state still exists during its exit actions

Exit actions run as part of leaving the state, before its state-owned facet and mounted citizens are removed. An exit computation may therefore read the data whose lifecycle is ending and project any durable result to an owner that remains active.

This example updates Museum, which survives the child transition. Updating data owned only by Gallery would be pointless here because that facet disappears when the exit completes.

Preserving the state means no exit

A targetless handler leaves the configuration unchanged, so it does not run exit actions. An internal descendant transition preserves its compound source, so that source does not exit either.

.reenter() makes the contrast explicit: it runs the source's exit actions, ends its mounted lifecycle, enters it again, and then runs its entry actions.

A boundary branch is an action list

Like entry(), bare exit() is incomplete. Chain one or more actions or remove the branch. The actions retain their authored order, and every exited state contributes its own list.

The later .update page will make the complete fold visible. For now, the two counters isolate the ownership rule: arrivals belong to entry; departures belong to exit.

Test the departure

  1. Complete two enter/return cycles and confirm that arrivals lead departures only while inside.
  2. Add another event that leaves Gallery and verify that it shares the same exit action.
  3. Move the exit branch to Museum and predict whether child-only navigation can trigger it.