https://github.com/async/flow
Portable store, async signal, and handler runtime for Async packages.
https://github.com/async/flow
Last synced: about 4 hours ago
JSON representation
Portable store, async signal, and handler runtime for Async packages.
- Host: GitHub
- URL: https://github.com/async/flow
- Owner: async
- License: mit
- Created: 2026-06-22T05:22:18.000Z (10 days ago)
- Default Branch: main
- Last Pushed: 2026-06-23T04:25:40.000Z (9 days ago)
- Last Synced: 2026-06-23T06:15:36.072Z (9 days ago)
- Language: JavaScript
- Homepage: https://async.github.io/flow/
- Size: 82 KB
- Stars: 1
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE
- Agents: AGENTS.md
Awesome Lists containing this project
README
# @async/flow
Portable store, async signal, and handler runtime for Async packages.
Flow is useful when an app needs signal-like state, event handlers, async
signals, and small workflow helpers without adopting a full statechart engine.
Pick the smallest layer that solves the problem:
- L1 primitives: use `createSignal`, `createComputed`, `createAsyncSignal`, and
`createStore` when an adapter or library needs explicit values and controllers.
- L2 Flow: use `flow(...)` when state changes should run through named events
and batched plain functions.
- L2.5 composition: use `compose(...)` and `parallel(...)` when a Flow handler
needs ordered or fan-out/fan-in work without a full helper vocabulary.
- L3 steps: use `set(...)`, `when(...)`, `branch(...)`, `dispatch(...)`, and
`after(...)` when repeated workflow wiring should read as reusable steps.
- Protocol brands: use `@async/flow/protocol` when two layers need to recognize
Flow objects without importing each other.
## Install
```bash
pnpm add @async/flow
```
## Quick Start
```js
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 });
counter.count; // 2
counter.phase; // "active"
dispatch(counter, "reset");
counter.count; // 0
counter.phase; // "idle"
```
A Flow instance combines:
- `store`: author-facing values with getter/setter behavior.
- `_`: non-enumerable internal controller namespace for `_` store fields.
- Handler methods from `on`, such as `counter.increment(input)`.
- `dispatch(name, input?)`: dynamic event execution on this Flow instance.
- `subscribe(fn)`: whole-flow change records with `{ name, input, store }`.
- `explain(name, input?)`: structured blocked-event reasons.
The package also provides `compose(...)`, `parallel(...)`, and `remember(...)`
for ordered handler steps. Use imported `can(...)` for event availability and
imported `inspect(...)` for public metadata snapshots.
## Store-Style Events
Every `on` handler is projected onto the Flow instance as a method. Whole-flow
subscribers receive one batched change record after each handler dispatch, and
`change.store` contains the public store snapshot after the handler completes.
```js
import { dispatch, flow } from "@async/flow";
function createDonutStore() {
return flow({
store: {
donuts: 0,
favoriteFlavor: "chocolate"
},
on: {
addDonut(store) {
store.donuts += 1;
},
changeFlavor(store, event) {
store.favoriteFlavor = event.flavor;
},
eatAllDonuts(store) {
store.donuts = 0;
}
}
});
}
const donutStore = createDonutStore();
donutStore.subscribe((change) => {
console.log(change.store);
});
donutStore.addDonut();
// logs { donuts: 1, favoriteFlavor: "chocolate" }
donutStore.changeFlavor({ flavor: "strawberry" });
// logs { donuts: 1, favoriteFlavor: "strawberry" }
const routedDonutStore = createDonutStore();
routedDonutStore.subscribe((change) => {
console.log(change.store);
});
dispatch(routedDonutStore, "addDonut");
// logs { donuts: 1, favoriteFlavor: "chocolate" }
dispatch(routedDonutStore, "changeFlavor", { flavor: "strawberry" });
// logs { donuts: 1, favoriteFlavor: "strawberry" }
```
Use direct methods when the event is known at author time. Use target-first
`dispatch(target, eventName, input?)` when an adapter receives the target or
event name dynamically, or when the same sender should work with different event
sinks.
## Store Values
Plain primitives and arrays become writable store values. Computed values are
read-only. Plain record values stay explicit; use `signal(value)` when an object
should be a single writable value.
```js
import { computed, flow, signal, status } from "@async/flow";
const cart = flow({
store: {
items: [],
settings: signal({ currency: "USD" }),
count: computed(function () {
return this.items.length;
}),
isEmpty: computed(function () {
return this.count === 0;
}),
phase: status("idle", ["idle", "ready"])
},
on: {
add(store, input) {
store.items = [...store.items, input.item];
store.phase = "ready";
}
}
});
cart.add({ item: { id: "sku_123" } });
cart.count; // 1
cart.items; // [{ id: "sku_123" }]
cart.settings = { currency: "EUR" };
```
Computed function callbacks read store values directly from `this`.
## Async Signals
`asyncSignal(loader)` declares a lazy async value with lifecycle state and
explicit controls. Loaders read Flow store data through `this.store`; lifecycle
tools are available through the function receiver.
```js
import { asyncSignal, flow } from "@async/flow";
const greeting = flow({
store: {
name: "World",
_request: asyncSignal(async function () {
const response = await fetch(`/api/greeting/${this.store.name}`, {
signal: this.signal
});
return response.text();
}),
get status() {
return this._request.status;
},
get value() {
return this._request.get();
}
},
on: {
fetch() {
return this.store._request.load();
},
reload() {
return this.store._request.reload();
},
cancel(_store, reason) {
return this.store._request.cancel(reason);
}
}
});
await greeting.fetch();
greeting.value; // loaded text
greeting.status; // "ready"
```
Lazy and immediate async signals can both use internal fields starting with `_` for
controller methods while exposing public getters as normal Flow values.
```js
const profile = flow({
store: {
_user: asyncSignal({ immediate: true }, async function () {
const response = await fetch("/api/user", { signal: this.signal });
return response.json();
}),
get user() {
return this._user.get();
},
get status() {
return this._user.status;
}
},
on: {
reloadUser() {
return this.store._user.reload();
}
}
});
profile.user; // current value
profile.status; // "loading", "ready", or "error"
```
More detail: [Async Signal Lifecycle](docs/async-signals.md).
## Compose And Step Workflows
Use `compose(...)` for ordered steps that should share one Flow handler input.
Each step receives `(store, input, previous)`. Use `parallel(...)` when one
ordered step should run independent effects before continuing. Use root-exported
step helpers when the repeated parts are store writes, gates, branches, event
dispatches, or scheduled follow-up events.
```js
import { compose, dispatch, every, flow, matches, not, parallel, set, status, when } from "@async/flow";
const checkout = flow({
store: {
step: status("shipping", ["shipping", "payment", "review"]),
canSubmit: true,
readyToSubmit: every(matches("step", "review"), (store) => store.canSubmit),
blocked: not((store) => store.readyToSubmit),
loading: false,
orderId: null
},
on: {
submit: compose([
when((store) => store.readyToSubmit, {
availability: true,
reason: "not_ready",
label: "Submit order"
}),
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)
])
}
});
```
`compose` stays synchronous until a step returns a promise-like value. Flow then
flushes the current synchronous batch and resumes later steps in a fresh batch.
That lets `loading = true` render before async work settles.
More detail: [Compose And Status Helpers](docs/compose-and-status.md).
`dispatch("event", payload?)` creates a reusable deferred sender. In a composed
Flow handler it dispatches to the current Flow receiver; outside Flow it can be
sent to any supported event sink. When the target is already a Flow instance and
the event is known, `checkout.ready(input)` is the direct equivalent of
`dispatch(checkout, "ready", input)`.
```js
const ready = dispatch("ready", { id: 1 });
ready.call(checkout);
ready.call(element);
ready.emit(emitter);
ready.send(sender);
dispatch(checkout, "ready", { id: 1 });
dispatch(element, "ready", { id: 1 });
dispatch(emitter, "ready", { id: 1 });
dispatch(sender, "ready", { id: 1 });
```
## Event Availability And Inspection
Flow can answer whether an event is registered and whether Flow-visible guards,
transitions, or explicit leading availability gates currently allow it without
dispatching the event.
```js
import { can, inspect } from "@async/flow";
can(checkout, "submit").get(); // false while the leading availability gate is blocked
checkout.explain("submit");
// { event: "submit", allowed: false, reason: "not_ready", source: "guard", label: "Submit order" }
checkout.explain("missing");
// { event: "missing", allowed: false, reason: "unknown_event" }
```
Use `inspect(...)` when adapters need stable public metadata:
```js
const description = inspect(checkout);
description.handlers; // ["submit"]
description.store.step.type; // "status"
```
Inspections expose names, current values, lifecycle state, and safe metadata.
They do not expose raw handlers or predicates.
Use `inspect(...)` for standalone status refs, computed refs, transition
helpers, and timer helpers without depending on a Flow instance:
```js
import { after, inspect, status } from "@async/flow";
const phase = status("idle", ["idle", "active"]);
const description = inspect(phase);
description.type; // "status"
description.value; // "idle"
```
`after(ms, callback, input?)` also works without a Flow instance. It returns a
cancellable timer helper.
```js
const markReady = after(100, (next) => {
phase.set(next);
}, "ready");
const cancel = markReady();
cancel();
```
## Runtime Options
The top-level authoring helper accepts either config or options plus config.
```js
flow(config);
flow({ scheduler, context }, config);
```
With two arguments, the first object is always runtime options and the second is
always Flow config.
Handlers receive `(store, input)`. Runtime capabilities are available through
method syntax or normal functions:
```js
const appFlow = flow(
{
context() {
return { logger: console };
}
},
{
store: {
count: 0
},
on: {
increment(store, input) {
store.count += input.by;
this.logger.log(store.count);
return this.dispatch("read");
},
read(store) {
return store.count;
}
}
}
);
```
Receiver capabilities include `this.store`, `this.refs`, `this.asyncSignals`,
`this.dispatch(name, input)`, `this.explain(name, input)`,
`this.after(ms, eventName, input)`, and `this.dispose(cleanup)`. Imported
`dispatch(...)`, `can(...)`, and `inspect(...)` can also receive a Flow handler
receiver.
## Root And Subpaths
The root package exports the complete opinionated Flow surface. Use subpaths
when a consumer wants a narrower entrypoint.
```js
import {
after,
asyncSignal,
bool,
branch,
compose,
computed,
createAsyncSignal,
createFlow,
createSignal,
createStore,
defineAsyncSignal,
defineFlow,
dispatch,
every,
flow,
matches,
not,
parallel,
remember,
set,
signal,
some,
status,
when
} from "@async/flow";
```
Graph helpers live in an opt-in subpath and are not re-exported from the root
entrypoint. They consume Flow instances through the shared inspection symbol
rather than importing runtime helpers:
```js
import { toGraph, toMermaid } from "@async/flow/graph";
```
Protocol symbols live in a tiny shared subpath:
```js
import { FLOW_INSPECT, ASYNC_SIGNAL } from "@async/flow/protocol";
```
Definition helpers, runtime primitives, composition primitives, and scheduler
controls are also available as narrow subpaths:
```js
import { defineFlow, defineSignal } from "@async/flow/define";
import { createFlow } from "@async/flow/runtime";
import { compose, parallel, remember } from "@async/flow/compose";
import { set, update, when } from "@async/flow/helpers";
import { asyncSignal, createAsyncSignal } from "@async/flow/async-signal";
import { createDefaultScheduler } from "@async/flow/scheduler";
```
Framework integrations that provide their own scheduler can use the
scheduler-free runtime and helper subpaths:
```js
import { createFlow } from "@async/flow/framework-runtime";
import { set, update, when, onError } from "@async/flow/helpers/core";
```
The `@async/flow/steps` subpath mirrors the step helpers for consumers that want
a step-oriented import name.
Builder helpers also live in an opt-in subpath. Use them when a graph
declaration should compile into ordinary Flow config while implementation
details come from handler and signal registries:
```js
import { flow } from "@async/flow";
import { toFlowConfig } from "@async/flow/builder";
const payment = flow(toFlowConfig(paymentGraph, {
handlers: {
canSubmit,
chargePayment
},
signals: {
isOnline
}
}));
```
## Docs
- [Docs Index](docs/README.md)
- [Layer Guide](docs/layers.md)
- [Signals, Computed, Async Signals, And Store](docs/state-and-store.md)
- [Async Signal Lifecycle](docs/async-signals.md)
- [Compose And Status Helpers](docs/compose-and-status.md)
## Package Checks
```bash
pnpm test
pnpm run typecheck
pnpm run pack:check
```