Groups and cancellation
A dashboard polls a metric every few seconds. In React the loop lives in a
useEffect, its cleanup clears the interval, and that is the only handle
anyone has. Pausing means a paused state that the effect depends on, so the
effect re-runs. "Refresh now" means a second effect or a ref to the
interval. The cleanup cancels the previous render's work and nothing else.
Wych gives every forked fiber a name, and any handler can cancel that name.
import { Action, Command, define, Next, summarizeCommand, Task } from "@wych/react";
import { Context, Effect, Layer, Schema } from "effect";
class Metrics extends Context.Service<Metrics, { readonly sample: Effect.Effect<number> }>()(
"Metrics",
) {}
const actions = Action({
Sampled: { value: Schema.Number },
Paused: {},
Resumed: {},
RefreshedNow: {},
});
const Dashboard = define({
props: Schema.Struct({ intervalMs: Schema.Number }),
state: Schema.Struct({ latest: Schema.Number, paused: Schema.Boolean }),
actions,
});
const poll = (intervalMs: number) =>
Command.keyed(
"poll",
Command.effect(actions.Sampled, (dispatch) =>
Effect.forever(
Effect.gen(function* () {
const metrics = yield* Metrics;
const value = yield* metrics.sample;
yield* dispatch(actions.Sampled, { value });
yield* Effect.sleep(intervalMs);
}),
),
),
);The book is a flat map from name to fibers, one per mount. A Group is a
string, not a { tag, key } pair, so a name means one thing per mount and
two handlers can share it on purpose.
The booking address is key ?? tag
An unkeyed command books under the tag of the action that issued it.
Command.keyed(name, command) sets the whole address instead, and the
outermost keyed wins.
const dashboard = Dashboard.create({
initialState: () => ({ latest: 0, paused: false }),
reducer: {
Mounted: (_payload, { state, props }) => [state, poll(props.intervalMs)],
Sampled: ({ value }, { draft }) => {
draft.latest = value;
return draft;
},
Paused: (_payload, { draft }) => {
draft.paused = true;
return [draft, Command.cancel("poll")];
},
Resumed: (_payload, { draft, props }) => {
draft.paused = false;
return [draft, Command.restart("poll", poll(props.intervalMs))];
},
RefreshedNow: (_payload, { state }) => [
state,
Command.effect((dispatch) =>
Effect.gen(function* () {
const metrics = yield* Metrics;
const value = yield* metrics.sample;
yield* dispatch(actions.Sampled, { value });
}),
),
],
PropsChanged: ({ previous }, { state, props }) =>
previous.intervalMs === props.intervalMs || state.paused
? state
: [state, Command.restart("poll", poll(props.intervalMs))],
Unmounted: (_payload, { state }) => [state, Command.cancel("poll")],
},
render: () => null,
});Two addresses are in use. The loop books under "poll", because it says so.
The one-off sample in RefreshedNow books under "RefreshedNow", because
nothing named it.
Paused cancels work that Mounted started. That is the case no
combinator inside a single handler's effect can reach: the fiber to
interrupt was forked by a different fold, from a different handler. It is
why cancel is a command in its own right and why the book is per mount
rather than per handler.
const snapshot = {
state: { latest: 0, paused: false },
props: { intervalMs: 1000 },
hooks: {},
};
console.log(summarizeCommand(Next.command(dashboard.reduce({ _tag: "Paused" }, snapshot))!));
// => { _tag: "Cancel", target: "poll" }The cancel is the reason run resolves at all for this feature. poll is
Effect.forever, so a fold that starts it and never cancels it never
finishes.
const metrics = Layer.succeed(Metrics)({ sample: Effect.succeed(42) });
const paused = await Effect.runPromise(
dashboard.run([{ _tag: "Mounted" }, actions.Paused.make()], {
props: { intervalMs: 1000 },
hooks: {},
layer: metrics,
}),
);
console.log(paused.state.paused);
// => trueWhy a bare-tag cancel misses keyed work
Command.cancel("RefreshedNow") interrupts the fibers booked under that
name. Work forked under keyed("poll") is addressed by "poll" alone, so a
cancel naming the tag does not reach it.
const stopRefresh = Command.cancel("RefreshedNow");
// => reaches the unkeyed one-off sample; the "poll" loop keeps runningThe narrowing follows from the flat namespace. A key replaces the address, so the tag is not part of it. Naming a group is therefore a decision about who may cancel it: keep a command unkeyed and only its own tag addresses it, or name it and every handler can.
restart is sugar
Take-latest is a cancel followed by a keyed replacement, in that order.
Resumed and PropsChanged above both need it. Command.restart
constructs exactly that pair.
console.log(summarizeCommand(Command.restart("poll", Command.none)));
// => { _tag: "Batch", commands: [{ _tag: "Cancel", target: "poll" }, { _tag: "Keyed", key: "poll", command: { _tag: "None" } }] }No ADT variant, no interpreter branch, no new devtools summary. The event stream shows the desugared batch, so what devtools report is what the runtime ran. Writing the pair by hand stays legal, and gets the ordering wrong when the cancel lands second.
Why tasks book under Task/${Name}
A task names its own group, prefixed, so it cannot
collide with an action tag in the same flat namespace. A feature with an
action tagged Sample and a task named Sample would otherwise interrupt
each other on every fold.
const loadHistory = Task("History", {
success: Schema.Array(Schema.Number),
});
console.log(summarizeCommand(loadHistory.cancel));
// => { _tag: "Cancel", target: "Task/History" }The command writes no state. A task left Pending after a cancel renders a
permanently disabled button, so the task's handle on the snapshot pairs the
two: tasks.history.cancel() writes Idle into the field the task owns and
returns this command beside it.
What batch is for
Command.batch interprets its members in order, under one context. The
order is the point: a cancel has to run before the fiber replacing it is
registered, and nothing inside that new fiber can interrupt its predecessor.
const replace = Command.batch(Command.cancel("poll"), poll(500));Composing two effects is a different job, and Effect.all inside one leaf
does it. That keeps both in one fiber, under one address, with one
interruption point.
const twoSamples = Command.effect(actions.Sampled, (dispatch) =>
Effect.gen(function* () {
const metrics = yield* Metrics;
const [first, second] = yield* Effect.all([metrics.sample, metrics.sample]);
yield* dispatch(actions.Sampled, { value: (first + second) / 2 });
}),
);Teardown
Groups are per mount, so two dashboards on one page never interrupt each
other. On unmount the runtime stops every running subscription first, then
interprets the Unmounted command with the feature's services still alive,
then lets in-flight commands finish and folds what they emit, bounded at
five seconds. Commands in flight are not interrupted by unmount on their
own: dashboard's Unmounted handler above reaches poll by name, with
Command.cancel("poll"), because a still-running poll would otherwise
keep the mount's layer alive until it finishes or the five-second bound is
reached.
A Cancel waits for the interrupted fibers' finalizers before the next
command is processed. That wait is what lets a batch put a cancel ahead of
its replacement, and it means a finalizer that hangs stalls that mount's
command loop. Teardown is bounded at five seconds and reports an abandoned
teardown as a defect; nothing bounds a hung finalizer mid-mount today. Keep
finalizers short.
Signatures are in commands; the recipes are in debounce and take-latest and subscribe to a stream.