Guide

.emit

Plan typed outward work and bind its Effect implementation at runtime.

.emit records typed outward intent in the statechart decision. The chart says what should happen; the runtime supplies the Effect implementation and owns how that work is delivered.

Describe the effect before you perform it.

.emit adds typed outward intent to an action list without running external code inside the statechart fold. A runtime Layer binds that intent to its Effect implementation.

1 · Change state without an effect

A transition does only what the chart declares.

Approve moves the order to Approved. No outward work is implied by that state name, and the runtime has no ReceiptRequested binding to enact.

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

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

export class Order extends State.Compound<Order>()("Order") {
  static states = States.make(() => [AwaitingApproval, Approved]);
}

class AwaitingApproval extends State.Atomic<AwaitingApproval>()(
  "AwaitingApproval",
) {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Approve, Approved),
  ]);
}

class Approved extends State.Atomic<Approved>()("Approved") {}

export const OrderChart = Statechart.make(Order);

2 · Plan an outward effect

.emit separates the intent from its implementation.

The same edge now plans a fielded ReceiptRequested emit. The chart owns the typed intent; its Layer supplies the Effect handler that performs outward work after the fold.

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

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

export class Order extends State.Compound<Order>()("Order") {
  static states = States.make(() => [AwaitingApproval, Approved]);
}

class AwaitingApproval extends State.Atomic<AwaitingApproval>()(
  "AwaitingApproval",
) {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Approve, Approved).emit(ReceiptRequested, ({ event }) => ({
      orderId: event.orderId,
    })),
  ]);
}

class Approved extends State.Atomic<Approved>()("Approved") {}

export const OrderChart = Statechart.make(Order);

const OrderLive = OrderChart.toLayer({
  emits: {
    ReceiptRequested: ({ event }) =>
      Effect.log("Receipt requested", event.orderId),
  },
}).pipe(Layer.provideMerge(StatechartEngine.layerMemory));

export { OrderLive };

A state name does not perform an effect

The first frame moves AwaitingApproval to Approved. That transition changes the modeled world, but it does not send a receipt merely because the destination has a meaningful name.

Statecharts execute what their action lists declare. Keeping the baseline effect-free makes the new outward boundary visible in the second frame.

Emit plans outward work

The second frame adds one action after the transition target:

on(Approve, Approved).emit(ReceiptRequested, ({ event }) => ({
  orderId: event.orderId,
}));

The action appends a ReceiptRequested plan. It does not call an email service, publish to a broker, or run arbitrary Effect code inside the deterministic chart fold.

Schema owns the payload

ReceiptRequested is an Effect Schema. Its producer must return the exact constructor input, so the outward payload stays connected to the declared event type rather than becoming a loose object at the effect boundary.

A payload-less emit uses .emit(Event). A fielded emit requires a producer, which may derive its value from the triggering event or from a Query over active state data.

The Layer owns the implementation

OrderChart.toLayer requires a handler for every outward event the chart can emit:

const OrderLive = OrderChart.toLayer({
  emits: {
    ReceiptRequested: ({ event }) =>
      Effect.log("Receipt requested", event.orderId),
  },
});

The chart owns the event vocabulary and where the intent is produced. The Layer owns the concrete Effect, its service requirements, and the deployment composition that satisfies them.

Delivery begins after the fold

The kernel folds the transition and emit plan without executing the handler. Once the statechart decision reaches the engine boundary, the engine may enact that plan through the bound Effect.

The live witness makes both sides visible: Approved is the chart result, while Handled order-2 is evidence from the effect binding. The second is not another state or an update smuggled back into the chart.

Raise and emit point in opposite directions

.raise places an event on the internal queue. Active chart topology consumes it during the same macrostep, before publication.

.emit points outward. A runtime handler consumes it after the fold, and its return value does not become another chart event. If an external result must affect the model later, bring that result back through an explicitly authored event, Activity outcome, or child protocol.

Delivery guarantees belong to the engine

The in-memory engine used in this page provides a process-local demonstration. A durable engine can commit the next snapshot and emit intent together, retain unsettled work, and redeliver after a crash. External delivery may therefore be at least once; receivers must use the engine's stable delivery identity when deduplication matters.

Those guarantees do not change the chart's .emit declaration. Engine choice owns the failure model while the chart continues to own the domain intent.

Test the boundary

  1. Approve the order in both frames and compare the state result with the effect-binding witness.
  2. Remove ReceiptRequested from the emits binding and read the missing implementation as a type error.
  3. Replace the in-memory engine with a durable engine and identify what must commit before delivery begins.