Compose And Status Helpers
compose(...) creates a handler from ordered steps.
import {
after,
bool,
branch,
can,
compose,
dispatch,
every,
flow,
inspect,
matches,
not,
parallel,
remember,
set,
some,
status,
transition,
when
} from "@async/flow";
const checkout = flow({
store: {
step: status("shipping", ["shipping", "payment", "review"]),
previousStep: null,
canSubmit: true,
loading: false,
orderId: null
},
on: {
next: remember(["step", "previousStep"], [
transition("step", {
shipping: "payment",
payment: "review"
})
]),
submit: compose([
when((store) => store.step === "review" && store.canSubmit),
set("loading", true),
parallel({
inventory(_store, input) {
return reserveInventory(input.form);
},
tax(_store, input) {
return calculateTax(input.form);
}
}),
async (_store, input) => {
const order = await submitOrder(input.form);
return order.id;
},
(store, _input, orderId) => {
store.orderId = orderId;
},
set("loading", false)
])
}
});
Step Contract
Each step receives:
step(store, input, previous);
store is the Flow store proxy. input is the stable dispatch input.
previous starts as undefined and becomes the last non-undefined value
returned by an earlier step.
compose preserves the receiver for every step, so method-style helpers can use
this.dispatch(...), this.store._user, and other Flow receiver capabilities.
const handler = compose([
function load(store) {
return this.store._user.load(store.userId);
},
function cache(store, _input, user) {
store.currentUser = user;
}
]);
Async Boundaries
If every step is synchronous, the composed handler returns synchronously. It
does not create a promise.
When a step returns a promise-like value, Flow flushes the current synchronous
batch and resumes the remaining steps in a fresh batch after the promise
settles. This makes loading states observable before async work completes.
sync segment:
loading = true
flush
async work resolves
continuation segment:
orderId = order.id
loading = false
flush
Helper Steps
set(key, value) writes store[key] = value.
set("loading", true);
set({ loading: false, error: null });
Values may also be derived from the current store, dispatch input, or previous
compose result:
set("orderId", (_store, _input, order) => order.id);
update(key, fn) writes a value derived from the current store value:
update("count", (count) => count + 1);
when(predicate) stops the composed handler when the predicate returns false:
compose([
when((store) => store.canSubmit),
set("loading", true)
]);
Use availability: true when a leading when(...) gate should participate in
pre-dispatch event inspection:
compose([
when((store) => store.canSubmit, {
availability: true,
reason: "cannot_submit",
label: "Submit order"
}),
set("loading", true)
]);
Only leading availability gates are lifted into can(...) and explain(...).
Later gates run at dispatch time only, because earlier steps may mutate the
store before they are evaluated.
dispatch(eventName, payload?) creates a reusable deferred sender. When the
sender is invoked, it dispatches to the invocation target. In composed Flow
handlers, that target is the current Flow receiver. Function payloads receive
(store, input, previous) when the sender runs as a Flow step.
dispatch("finish", (_store, input) => ({ source: input.source }));
Deferred senders can also be reused with other event sinks:
const ready = dispatch("ready", { id: 1 });
ready.call(checkout);
ready.call(element);
ready.emit(emitter);
ready.send(sender);
Use target-first dispatch for immediate sends:
dispatch(checkout, "ready", { id: 1 });
dispatch(this, "ready", { id: 1 });
dispatch(element, "ready", { id: 1 });
dispatch(emitter, "ready", { id: 1 });
dispatch(sender, "ready", { id: 1 });
after(ms, eventName, input?) schedules another Flow event through the current
Flow receiver.
after(5000, "checkJobStatus", (store) => ({ id: store.jobId }));
after(ms, callback, input?) creates a standalone cancellable timer helper.
The callback receives either the input passed when the helper is called or the
fixed input passed when the helper was created.
const markReady = after(100, (next) => {
phase.set(next);
}, "ready");
const cancel = markReady();
cancel();
branch(cases) runs the first matching case. Tuple cases are
[condition, handler]; a bare handler is the default case. Conditions may be
predicate functions or computed boolean helpers.
branch([
[(store) => store.jobStatus === "SUCCEEDED", dispatch("reportJobSucceeded")],
[(store) => store.jobStatus === "ERROR", dispatch("reportJobError")],
compose([
set("step", "WaitForCompletion"),
after(5000, "checkJobStatus")
])
]);
onError(handle, handler) maps sync throws and async rejections:
onError(
(error) => ({ error: error.message }),
async () => {
throw new Error("failed");
}
);
Plain object handler results are applied as store updates by Flow dispatch.
When a composed step returns a plain object as service data instead of store
updates, map it to a primitive or write it into the store before the composed
handler finishes.
parallel(branches) runs independent branch steps at the same point in a
composed handler, waits for every async branch, and returns undefined.
compose([
set({ syncing: true, error: null }),
parallel({
user() {
return this.store._user.reload();
},
cart() {
return this.store._cart.reload();
}
}),
set("syncing", false)
]);
Use parallel(...) for effect fan-out/fan-in. It does not create parallel
state regions and it does not collect branch results by default.
remember(mapping, steps) captures source store values before scoped work and
writes those captured values to explicit target fields after the scoped work
succeeds, but only when a source changed.
remember(["step", "previousStep"], [
transition("step", {
shipping: "payment",
payment: "review"
})
]);
Multiple mappings are supported:
remember([
["step", "previousStep"],
["mode", "previousMode"]
], [
transition("step", { shipping: "payment" }),
set("mode", "editing")
]);
remember(...) stores previous values in author-chosen fields. It does not add
hidden history, rollback, or transaction behavior.
Status Helpers
status(initial, allowed?) creates a writable finite status signal. It works
standalone or as a Flow store declaration.
const order = flow({
store: {
step: status("shipping", ["shipping", "payment", "review"]),
canSubmit: true,
canGoNext: can("step", "next"),
inReview: matches("step", "review"),
readyToSubmit: every(matches("step", "review"), (store) => store.canSubmit),
submitBlocked: not((store) => store.readyToSubmit)
},
on: {
next: transition("step", {
shipping: "payment",
payment: "review"
}),
submit: guard(
(store) => store.readyToSubmit,
set("submitted", true)
)
}
});
transition(statusName, rules) writes the next status when a rule matches.
Missing matches are no-ops. Rule when fields accept the same boolean
conditions as guard(...) and when(...).
transition("step", {
from: "review",
to: "submitted",
when: every(matches("step", "review"), (store) => store.canSubmit)
});
Use a status name when the transition belongs to a Flow store; that keeps
can(...), explain(...), and inspect(...) tied to public store metadata.
Use live status refs when the helpers are standing alone:
const phase = status("idle", ["idle", "dragging", "dropped"]);
const startDragging = transition(phase, {
idle: "dragging"
});
const drop = transition(phase, {
dragging: "dropped"
});
const canStartDragging = can(startDragging);
const canDrop = can(drop);
const isDragging = matches(phase, "dragging");
const dropReady = every(isDragging, canDrop);
canStartDragging.get(); // true
canDrop.get(); // false
isDragging.get(); // false
dropReady.get(); // false
startDragging();
phase.get(); // "dragging"
canStartDragging.get(); // false
canDrop.get(); // true
isDragging.get(); // true
dropReady.get(); // true
drop();
phase.get(); // "dropped"
canDrop.get(); // false
set(statusRef, value), update(statusRef, fn), bool(ref),
every(ref, ...), some(ref, ...), and not(ref) also work directly with live
refs. Use store names when a helper should integrate with Flow inspection; use
refs when the helper should stand alone.
Use inspect(...) for standalone helper metadata and Flow inspection:
inspect(phase);
// { type: "status", value: "dropped", allowed: ["idle", "dragging", "dropped"] }
inspect(startDragging);
// {
// type: "transition",
// target: {
// type: "status",
// value: "dropped",
// allowed: ["idle", "dragging", "dropped"]
// },
// rules: [{ conditional: false, from: "idle", to: "dragging" }]
// }
can(statusName, eventName) computes whether a transition handler can move
from the current status.
can(eventName) computes whether an event is available now. It infers the
status metadata from the event instead of repeating the status name.
can(flow, eventName, input?) returns a standalone computed ref that follows a
Flow instance's event availability.
can(transitionStep, input?) returns a standalone computed ref for a
transition(statusRef, rules) helper.
const checkout = flow({
store: {
step: status("shipping", ["shipping", "payment", "review"]),
canAdvance: can("next")
},
on: {
next: transition("step", {
shipping: "payment",
payment: "review"
})
}
});
matches(statusName, value) computes whether the current status matches a
value. matches(statusRef, value) returns a standalone computed ref for a live
status signal. The value may also be an array.
const dragging = matches("phase", ["dragging", "overTarget"]);
const phase = status("idle", ["idle", "dragging"]);
const isDragging = matches(phase, "dragging");
phase.set("dragging");
isDragging.get(); // true
bool(condition) coerces one condition to a computed boolean. every(...),
some(...), and not(...) compose boolean conditions without inline &&,
||, or !.
const cardDrag = flow({
store: {
phase: status("idle", ["idle", "dragging", "overTarget"]),
cardId: null,
overColumnId: null,
dragging: matches("phase", ["dragging", "overTarget"]),
dropReady: every(
matches("phase", "overTarget"),
(store) => store.cardId,
(store) => store.overColumnId
),
blocked: not(can("drop"))
},
on: {
drop: guard(
every(
matches("phase", "overTarget"),
(store) => store.cardId,
(store) => store.overColumnId
),
set("dropped", true)
)
}
});
Conditions can be predicate functions, computed definitions from helpers such
as matches(...) or can(...), or boolean helpers. Predicate conditions
receive (store, input, previous), matching composed handler steps.
guard(predicate, handler) skips the handler when the predicate is false.
Imported can(flow, eventName, input?) returns a computed event availability
ref without dispatching the event. Availability uses Flow-visible transition
metadata, guard metadata, and explicit leading when(..., { availability: true })
gates. Plain composed handlers remain callable unless they publish availability
metadata.
can(checkout, "next").get(); // true
can(checkout, "submit", { confirm: true }).get();
flow.explain(eventName, input?) and receiver
this.explain(eventName, input?) return stable reason data for allowed and
blocked events. Applications should map reason codes to user-facing text.
checkout.explain("submit");
// {
// event: "submit",
// allowed: false,
// reason: "guard_failed",
// source: "guard"
// }
Built-in reason codes are:
unknown_event
allowed
plain_handler
no_matching_transition
transition_condition_failed
guard_failed
Availability gates can return custom reason values through their metadata.
Transition rules and guards may carry reason and label metadata:
guard(
(store) => store.step === "review" && store.canSubmit,
transition("step", { review: "submitted" }),
{
reason: "cannot_submit",
label: "Submit order"
}
);
Imported inspect(flow) returns public inspection data for store entries,
handlers, transitions, and guards. Availability gates lifted from composed
handlers appear as guard metadata.
const description = inspect(checkout);
description.handlers; // ["next", "submit"]
description.store.step.type; // "status"
description.transitions.next.status; // "step"
Inspections are fresh snapshots. They do not expose raw handler
functions, guard predicates, availability predicates, or transition condition
functions.
Transition, standalone transition, standalone dispatch, standalone after, guard,
and availability metadata use public symbols:
import {
AVAILABILITY,
GUARD,
STANDALONE_AFTER,
STANDALONE_DISPATCH,
STANDALONE_TRANSITION,
TRANSITION
} from "@async/flow";