Guide

.raise

Route typed consequences through active topology inside one macrostep.

.raise sends a Schema-defined event through the chart's own internal queue. Its consequences can cross state boundaries and involve another active owner without becoming another client command.

Route internal consequences before publication.

.raise appends a typed event to the macrostep's internal queue. The chart routes that consequence through its newly active topology before it publishes the stable result.

1 · Expose a second command

A public follow-up reveals half the decision.

Submit enters Submitted, but the id remains unrecorded until a caller sends RecordSubmission. The client can observe and interrupt the domain decision between those two commands.

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

const Submit = Schema.TaggedStruct("Submit", {
  orderId: Schema.String,
});

const RecordSubmission = Schema.TaggedStruct("RecordSubmission", {
  orderId: Schema.String,
});

export class Checkout extends State.Compound<Checkout>()("Checkout", {
  recordedOrderId: Schema.String.pipe(
    Schema.withConstructorDefault(Effect.succeed("")),
  ),
}) {
  static states = States.make(() => [Editing, Submitted]);
}

class Editing extends State.Atomic<Editing>()("Editing") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Submit, Submitted),
  ]);
}

class Submitted extends State.Atomic<Submitted>()("Submitted") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(RecordSubmission).update(Checkout, ({ event }) => ({
      recordedOrderId: event.orderId,
    })),
  ]);
}

export const CheckoutChart = Statechart.make(Checkout);

2 · Keep the consequence internal

.raise completes the decision before publication.

The Submit edge raises a fielded RecordSubmission event. Submitted consumes it through ordinary event selection in the next microstep, before the macrostep publishes.

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

const Submit = Schema.TaggedStruct("Submit", {
  orderId: Schema.String,
});

const RecordSubmission = Schema.TaggedStruct("RecordSubmission", {
  orderId: Schema.String,
});

export class Checkout extends State.Compound<Checkout>()("Checkout", {
  recordedOrderId: Schema.String.pipe(
    Schema.withConstructorDefault(Effect.succeed("")),
  ),
}) {
  static states = States.make(() => [Editing, Submitted]);
}

class Editing extends State.Atomic<Editing>()("Editing") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Submit, Submitted).raise(RecordSubmission, ({ event }) => ({
      orderId: event.orderId,
    })),
  ]);
}

class Submitted extends State.Atomic<Submitted>()("Submitted") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(RecordSubmission).update(Checkout, ({ event }) => ({
      recordedOrderId: event.orderId,
    })),
  ]);
}

export const CheckoutChart = Statechart.make(Checkout);

3 · Drain in authored order

The internal event queue is FIFO.

Submit raises the primary record and then an audit record. Submitted consumes both in that order, and one committed snapshot contains the complete ordered list.

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

const Submit = Schema.TaggedStruct("Submit", {
  orderId: Schema.String,
});

const RecordSubmission = Schema.TaggedStruct("RecordSubmission", {
  orderId: Schema.String,
});

export class Checkout extends State.Compound<Checkout>()("Checkout", {
  recordedOrderIds: Schema.Array(Schema.String).pipe(
    Schema.withConstructorDefault(Effect.succeed([])),
  ),
}) {
  static states = States.make(() => [Editing, Submitted]);
}

class Editing extends State.Atomic<Editing>()("Editing") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Submit, Submitted)
      .raise(RecordSubmission, ({ event }) => ({ orderId: event.orderId }))
      .raise(RecordSubmission, ({ event }) => ({
        orderId: `audit:${event.orderId}`,
      })),
  ]);
}

class Submitted extends State.Atomic<Submitted>()("Submitted") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(RecordSubmission).update(Checkout, ({ event, target }) => ({
      recordedOrderIds: [...target.recordedOrderIds, event.orderId],
    })),
  ]);
}

export const CheckoutChart = Statechart.make(Checkout);

Begin with the public protocol

The first frame exposes both Submit and RecordSubmission to the client. Submit moves Editing to Submitted; only the second send records the order id.

That protocol publishes a valid but incomplete intermediate result. After the first command, Submitted is active while the recorded list is still empty. A caller must know the chart's internal follow-up rule and remain available to finish it.

Raise appends a typed event

The second frame moves that follow-up into the selected Submit edge:

on(Submit, Submitted).raise(RecordSubmission, ({ event }) => ({
  orderId: event.orderId,
}));

RecordSubmission is an Effect Schema, so its producer must return the exact constructor input. The payload is encoded and decoded through that Schema before a handler reads it. A payload-less event can use .raise(Event) without a producer.

The new configuration handles the event

The Submit transition enters Submitted first. Motive then takes the raised event from its internal queue and offers it to the active topology, where Submitted owns the targetless handler that updates Checkout.

This is ordinary event selection, not a direct call to that handler. Moving the listener to a coactive ancestor would preserve the behavior; leaving it on the now-inactive Editing state would not.

The internal queue is FIFO

The final frame raises the primary record followed by an audit record. Both events have the same tag, but their payloads remain distinct and drain in authored first-in, first-out order:

.raise(RecordSubmission, ({ event }) => ({ orderId: event.orderId }))
.raise(RecordSubmission, ({ event }) => ({ orderId: `audit:${event.orderId}` }));

Submitted therefore records order-123 before audit:order-123. Chaining more actions does not turn the queue into parallel work or reverse its order.

The client observes one stable result

Eventless transitions settle before Motive handles the next raised event. The macrostep continues until neither an eventless edge nor an internal event can advance it, or until the authored root completes.

Only then does the engine publish the committed snapshot. In the second and third frames, one client Submit produces one new committed view containing every internal consequence. There is no client-visible snapshot between Submitted becoming active and its listener recording the id.

Unhandled raised events are discarded

If no transition in the active topology matches a raised event, Motive silently discards it. That matches the statechart execution model, but it also means .raise is not a durable mailbox and not an assertion that a listener must exist.

Author both ends when the consequence is required. The diagram makes the declared listener discoverable; focused tests prove the active configuration that receives it.

Choose the owner of the consequence

Use .update when the current action list plainly owns a data change. Use .raise when the consequence belongs to another part of the topology and should still participate in the same macrostep.

Use a client send when the outside world genuinely owns the next decision. Do not expose a second command only to make the client relay an implementation detail back into the same chart.

Test the internal protocol

  1. Submit once in each frame and compare both the recorded values and committed-snapshot count.
  2. Reverse the two raises in the final frame and predict the recorded order.
  3. Move the listener from Submitted to Checkout, then to Editing, and explain each result from the active topology.