.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
- Approve the order in both frames and compare the state result with the effect-binding witness.
- Remove
ReceiptRequestedfrom theemitsbinding and read the missing implementation as a type error. - Replace the in-memory engine with a durable engine and identify what must commit before delivery begins.