Composable async flows built from small, immutable steps: sequence, branch, race and run in parallel, with cancellation built in.
Write async logic as a list of small steps instead of one long function. Each step (a hook) receives a frozen context object and returns the next one, like a reducer.
npm install flow-hooksimport { cancel, flow, when } from 'flow-hooks';
const checkout = [
// Stop early, without an error, when there's nothing to do.
when((ctx) => ctx.items.length === 0, cancel()),
// Each step returns the next context.
(ctx) => ({ ...ctx, total: ctx.items.reduce((sum, i) => sum + i.price, 0) }),
// Only runs when the condition is true.
when(
(ctx) => ctx.coupon === 'SAVE10',
(ctx) => ({ ...ctx, total: ctx.total * 0.9 }),
),
// Return nothing for side effects. The context passes through unchanged.
(ctx) => {
console.log(`Charging $${ctx.total}`);
},
];
const order = await flow(checkout, {
items: [{ price: 60 }, { price: 40 }],
coupon: 'SAVE10',
});
// Charging $90
order.total; // 90Compare one long function:
async function checkout(cart, user) {
if (cart.items.length === 0) return;
const total = sum(cart.items);
const [stock, shipping] = await Promise.all([
reserveStock(cart),
quoteShipping(user),
]);
let payment;
try {
payment = await chargeWithStripe(user, total);
} catch {
payment = await chargeWithPaypal(user, total);
}
await sendConfirmation(user, payment);
}with the same process as a flow:
const checkout = [
when((ctx) => ctx.cart.items.length === 0, cancel()),
addTotal,
every([reserveStock, quoteShipping]), // in parallel
any([chargeWithStripe, chargeWithPaypal]), // first one to succeed
sendConfirmation,
];
await flow(checkout, { cart, user }, { signal: AbortSignal.timeout(10_000) });- You can read the process at a glance. Each line is one step; the details live in small, named functions.
- Each step is easy to test. A hook is a plain function: call it with an object and check what it returns.
- Steps can't interfere. The context is frozen, so no step can change data another step, a parallel step or the caller is holding.
- Cancellation reaches everything. One
AbortSignalgoes to every hook. Whenevery()fails orany()has a winner, the leftover work is aborted.
For a three-line function you don't need any of this. It pays off once a process has branches, parallel work, early exits and retries.
| Function | What it does |
|---|---|
flow(hooks, context?, { signal? }) |
Runs hooks in order. Resolves with the final, frozen context. |
runFlow(hooks, context?, { signal? }) |
Same, but resolves with { context, cancelled }. |
when(condition, hook | hooks[]) |
Runs the hook(s) only if condition(ctx) is truthy. |
every(hooks[]) |
Runs hooks in parallel, waits for all and merges their results. |
any(hooks[]) |
Runs hooks in parallel and continues with the first to succeed. |
cancel() / cancel(error) |
Stops the flow quietly, or fails it with error. |
A hook is (ctx, { signal }) => nextCtx, sync or async. Return a new object to change the context, return nothing to keep it, and throw to fail the flow.
radenkovic.github.io/flow-hooks
- Getting started: learn it in nine short steps.
- Examples: complete, runnable programs: HTTP handler, webhook worker, parallel loading, retries, tests. They're in
examples/. - Recipes: retries, timeouts, fallbacks, if/else, caching, logging, Express, React.
- Common mistakes: the errors you're most likely to see, and the fixes.
- API reference and TypeScript.
Written in TypeScript with no runtime dependencies. Ships ESM and CommonJS, and runs in Node.js 22+ and modern browsers.
Issues and pull requests are welcome. See CONTRIBUTING.md for setup; npm run check runs everything CI runs.