@async/flow is built in layers. Each layer keeps the lower layer available and
adds only the authoring shape needed for that level of workflow structure.
Use the lowest layer that keeps the code clear. Move up when repeated patterns become part of the application design instead of local state mechanics.
L1 is the live state layer. It is useful for adapters, framework integrations, tests, and small state units that do not need named events yet.
Signals are writable values with get, set, update, subscribe, and
snapshot. Writable values also expose restore. Computed values are
read-only values derived from signals or other store values.
import { createComputed, createSignal } from "@async/flow";
const count = createSignal(1);
const doubled = createComputed(() => count.value * 2);
count.set(2);
doubled.value; // 4Async signals are async value controllers with load, reload, cancel,
set, lifecycle status, snapshots, and subscriptions.
import { createAsyncSignal } from "@async/flow";
const greeting = createAsyncSignal(async function (input) {
return `Hello ${input.name}`;
});
await greeting.load({ name: "Ada" });
greeting.status; // "ready"
greeting.value; // "Hello Ada"Stores wrap signals, computed values, async signals, and plain writable values in one author-facing proxy while keeping intentionally internal async signal controllers available.
import { asyncSignal, computed, createStore, signal } from "@async/flow";
const state = createStore({
count: 0,
settings: signal({ currency: "USD" }),
doubled: computed(function () {
return this.count * 2;
}),
_greeting: asyncSignal(async function () {
const currency = this.store.settings.currency;
return `Hello ${currency}`;
}),
get greeting() {
return this._greeting.get();
}
});
state.store.count += 1;
state.store.doubled; // 2
state.store.count; // 1
await state.store._greeting.load();
state.store.greeting; // "Hello USD"Choose L1 when:
- You are integrating Flow state into another runtime or framework.
- You need async signal controllers.
- There is no useful event vocabulary yet.
- Tests or adapters need small state units without a full Flow instance.
L2 adds named events, handler batching, snapshots, receiver capabilities, and finite status values. Handlers are still just functions.
import { dispatch, flow, status } from "@async/flow";
const counter = flow({
store: {
count: 0,
phase: status("idle", ["idle", "active"])
},
on: {
increment(store, input = {}) {
store.count += input.by ?? 1;
store.phase = "active";
},
reset(store) {
store.count = 0;
store.phase = "idle";
}
}
});
counter.increment({ by: 2 });
dispatch(counter, "reset");Choose L2 when:
- State changes should be named actions such as
increment,fetch, orsubmit. - Subscribers should see batched handler changes.
- Author code should call known events directly, such as
checkout.submit(input). - Adapters need target-first
dispatch(target, eventName, input?)for dynamic event routing. - Handlers need
this.dispatch(...),this.after(...), internal controllers, or injected runtime context. - UI controls or adapters need imported
can(...),explain(...), orinspect(...)without dispatching events.
L2.5 keeps plain functions but lets one handler read as ordered work. Use
compose(...) for steps and parallel(...) for fan-out/fan-in effects. This
layer does not require guards, branches, store-write helpers, or scheduling
helpers.
import { compose, flow, parallel, status } from "@async/flow";
const checkout = flow({
store: {
step: status("review", ["review", "submitted"]),
loading: false,
orderId: null
},
on: {
submit: compose([
(store) => {
store.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;
store.step = "submitted";
store.loading = false;
}
])
}
});Choose L2.5 when:
- A handler has ordered synchronous and async segments.
- Independent effects should start at the same ordered point.
- You want step-level
previousvalues without introducing the L3 helper vocabulary.
L3 adds reusable step helpers. These helpers are still ordinary Flow handler functions, but common workflow wiring reads declaratively.
import { after, branch, compose, dispatch, flow, set, status, when } from "@async/flow";
const job = flow({
store: {
step: status("SubmitJob", [
"SubmitJob",
"WaitForCompletion",
"GetJobStatus",
"JobSucceeded",
"JobError"
]),
jobStatus: undefined
},
on: {
determineCompletion: compose([
when((store) => store.step === "GetJobStatus"),
branch([
[(store) => store.jobStatus === "SUCCEEDED", dispatch("reportJobSucceeded")],
[(store) => store.jobStatus === "ERROR", dispatch("reportJobError")],
compose([
set("step", "WaitForCompletion"),
after(5000, "checkJobStatus")
])
])
])
}
});Choose L3 when:
- Several handlers share store-write, gate, branch, dispatch, or scheduling patterns.
- You want workflow code to read as reusable steps instead of one long handler.
- You need
set(...)projections from dispatch input or previous compose results. - You need
after(...)to schedule follow-up events without writing a custom receiver function.
Start with L1 for primitives, move to L2 when state changes have event names, use L2.5 when one event has ordered or parallel work, and move to L3 when the same workflow wiring repeats.
The only half-step is L2.5 because composition changes handler structure without adding a new domain vocabulary. L1 does not need a half-step: definitions and runtime primitives are part of the same primitive/store layer. L3 does not need a half-step: new helpers should either stay as reusable steps or become a separate domain package.