@async/flow

Layer Guide

@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: Primitives And Store

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 And Computed Values

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; // 4

Async Signals

Async 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"

Store Proxy

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: Flow Events And Status

L2 adds named events, handler batching, snapshots, receiver capabilities, and

finite status values. Handlers are still just functions.

import { 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.dispatch("increment", { by: 2 });

Choose L2 when:

  • State changes should be named actions such as increment, fetch, or

submit.

  • Subscribers should see batched handler changes.
  • Handlers need this.dispatch(...), this.after(...), internal controllers,

or injected runtime context.

  • UI controls or adapters need imported can(...), explain(...), or

inspect(...)

without dispatching events.

L2.5: Composition And Parallel Effects

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 previous values without introducing the L3 helper

vocabulary.

L3: Step Helpers

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.

Moving Up The Layers

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.