The whole path in one chart
A statechart is more than a diagram. It gives one typed model to the event you accept, the states it may enter, the effects it starts, and the value it produces when it finishes.
The checkout below begins in Draft. A positive SubmitOrder event records the total
and enters the nested Checkout state. Authorizing starts an Effect activity; its
typed success moves through the nested final state, while its typed error ends the
order as Declined. Finishing Checkout completes the root as Fulfilled.
Read the authored structure
Read the program in five passes:
SubmitOrder,CardDeclined, andAuthorizePaymentdefine the schemas at the chart's boundaries.Order, its child states, andStates.makedefine the hierarchy. The first child in each compound state is its initial state.Transitions.makeconnects events and activity results to target states. The positive-total guard can refuse an otherwise valid event, and the transition action updates the root's typed data.Statechart.makeassembles the geometry.toLayerbinds every activity the geometry requires and supplies an in-memory engine.runCheckoutaddresses one instance through the chart's typed client. A handled submission awaits a final output; a refused submission reads the unchanged snapshot instead.
Run the complete example
This is the complete compiled source. It exports the chart, its live Layer, a reusable
Effect function, and crashCourseProgram. Running that program exercises both a
successful order and a zero-total submission refused by the guard.
import {
Activity,
Query,
State,
Statechart,
StatechartEngine,
States,
Transitions,
} from "@motive/motive";
import { Cause, Effect, Layer, Option, Schema } from "effect";
class SubmitOrder extends Schema.TaggedClass<SubmitOrder>()("SubmitOrder", {
total: Schema.Finite,
}) {}
class CardDeclined extends Schema.TaggedError<CardDeclined>()("CardDeclined", {
reason: Schema.String,
}) {}
const AuthorizePayment = Activity.make("AuthorizePayment", {
input: Schema.Struct({ total: Schema.Finite }),
success: Schema.Struct({ receiptId: Schema.String }),
error: CardDeclined,
});
const hasPositiveTotal = Query.gen(function* () {
const event = yield* Query.event(SubmitOrder);
return event.total > 0;
});
class Order extends State.Compound<Order>()("Order", {
total: Schema.Finite.pipe(Schema.withConstructorDefault(Effect.succeed(0))),
}) {
static states = States.make(() => [Draft, Checkout, Fulfilled, Declined]);
static transitions = Transitions.make(this, ({ on }) => [
on(State.done(Checkout), Fulfilled, ({ event }) => ({
receiptId: event.value.receiptId,
})).reenter(),
]);
}
class Draft extends State.Atomic<Draft>()("Draft") {
static transitions = Transitions.make(this, ({ on }) => [
on(SubmitOrder, Checkout)
.when(hasPositiveTotal)
.update(Order, ({ event }) => ({ total: event.total })),
]);
}
class Checkout extends State.Compound<Checkout>()("Checkout") {
static states = States.make(() => [Authorizing, Authorized]);
}
class Authorizing extends State.Atomic<Authorizing>()("Authorizing") {
static transitions = Transitions.make(this, ({ invoke, on }) => [
invoke(
AuthorizePayment,
Query.gen(function* () {
const order = yield* Order;
return { total: order.total };
}),
),
on(AuthorizePayment.Done, Authorized, ({ event }) => ({
receiptId: event.value.receiptId,
})),
on(AuthorizePayment.Error, Declined, ({ event }) =>
Option.getOrThrow(Cause.findErrorOption(event.cause)),
),
]);
}
class Authorized extends State.Done<Authorized>()(
"Authorized",
Schema.Struct({
receiptId: Schema.String,
}),
) {}
class Fulfilled extends State.Done<Fulfilled>()(
"Fulfilled",
Schema.Struct({
receiptId: Schema.String,
}),
) {}
class Declined extends State.Error<Declined>()("Declined", CardDeclined) {}
export const CheckoutChart = Statechart.make(Order);
export const CheckoutLive = CheckoutChart.toLayer({
activities: {
AuthorizePayment: ({ input }) =>
input.total > 500
? Effect.fail(new CardDeclined({ reason: "manual review required" }))
: Effect.succeed({ receiptId: `receipt-${input.total}` }),
},
}).pipe(Layer.provideMerge(StatechartEngine.layerMemory));
export const runCheckout = Effect.fn("CrashCourse.runCheckout")(function* (
instance: string,
total: number,
) {
const instanceId = yield* Schema.decodeEffect(Statechart.RootInstanceId)(instance);
const checkout = (yield* CheckoutChart.client)(instanceId);
const before = yield* checkout.snapshot;
const sent = yield* checkout.send.SubmitOrder({ total });
switch (sent._tag) {
case "Handled":
return { before, sent, terminal: yield* checkout.awaitTerminal };
case "Refused":
return { before, sent, after: yield* checkout.snapshot };
case "NotStarted":
return { before, sent };
}
});
export const crashCourseProgram = Effect.all([
runCheckout("accepted-order", 42),
runCheckout("refused-order", 0),
]).pipe(Effect.provide(CheckoutLive), Effect.scoped);
The successful result reaches Fulfilled with receiptId: "receipt-42". The refused
result has _tag: "Refused" and remains in Draft. Refusal is a normal value from
send, not a failure in the Effect error channel. Change the accepted total to a
number above 500 to follow the activity's typed error channel to Declined.
What the types now own
The chart infers its inbound event union from the reachable transitions. State data is decoded and updated through its schema. Activity input, success, and error values flow into the matching handlers and transitions. The root's final states define its output. Changing one of those boundaries makes the dependent authoring sites fail to compile instead of silently drifting.
The in-memory engine is useful for local processes, tests, and this first run. Engine choice changes operational guarantees, not the authored chart. Continue to Choose an Engine to compare those guarantees, or return to the Introduction to revisit the chart and engine boundary.