Async
Reactive, awaitable async controllers — start, stop, rerun, and read state as signals.
The async utility wraps an async function into a reactive, awaitable controller. You can start, stop, and rerun async operations, and read their state, value, and errors as reactive signals.
import { signal } from "elements-kit/signals";
import { async } from "elements-kit/utilities/async";
export const query = signal("hello");
export const search = async(async () => {
const input = query();
console.log(`[async] run input="${input}"`);
await new Promise((r) => setTimeout(r, 500));
const result = `result for "${input}"`;
console.log(`[async] done →`, result);
return result;
}).start();
import { signal } from "elements-kit/signals";
import { async } from "elements-kit/utilities/async";
test("start runs the fn and exposes state", async () => {
const op = async(async () => 42);
op.start();
await op;
expect(op.state).toBe("fulfilled");
expect(op.value).toBe(42);
});
test("stop halts reactive execution", async () => {
const input = signal(1);
let runs = 0;
const op = async(async () => {
runs++;
return input();
});
op.start();
await op;
op.stop();
input(2);
// give any pending microtask a chance
await Promise.resolve();
expect(runs).toBe(1);
});
test("reruns when a tracked signal changes", async () => {
const input = signal(1);
const op = async(async () => input() * 10);
op.start();
await op;
expect(op.value).toBe(10);
input(5);
await op;
expect(op.value).toBe(50);
op.stop();
});
Basic usage
import { } from "elements-kit/utilities/async";
const = (() => ("/api/items").(() => .()));
.(); // begin reactive executionControl methods
Start for reactive execution, run for one-shot, stop to tear down, reset to clear.
op.start(); // run and track reactive dependencies — reruns when signals change
op.run(); // run once without tracking — does not rerun on signal changes
op.stop(); // stop reactive reruns and run cleanup logic
op.reset(); // stop, then clear state back to `idle`When to start() vs run()
start() enables fetching automatically. The body is tracked, so the query re-runs whenever a signal it reads before the first await changes. Reach for it when the load is parameterised by reactive state — "fetch user X whenever userId changes". The data-fetching recipe uses start() with reactive params.
run() is one-shot and untracked. Reach for it when an external event drives the load — intersection, click, form submit — especially when the body writes to the same signals it reads. The infinite-scroll recipe uses run() for sentinel-driven page loads.
start() cascades when the body writes its own deps
A tracked body that mutates a signal it depends on retriggers itself. Use run() for externally-driven loads instead.
// Wrong — body reads `cursor`, body writes `cursor`. Each write retriggers the
// tracking effect; on first mount this fetches every page in a tight loop.
const loadMore = async(async () => {
const page = await fetchPage(cursor());
cursor(page.next);
}).start();
// Right — `run()` is untracked, so the write doesn't re-fire the body.
// Drive it from an event (intersection, click, etc.).
const loadMore = async(async () => {
const page = await fetchPage(cursor());
cursor(page.next);
});
sentinelInView.subscribe(() => loadMore.run());Async implements Symbol.dispose, so using stops it automatically when it goes out of scope:
{
using op = async(() => fetch("/api/data").then((r) => r.json())).start();
await op;
console.log(op.value);
} // op.stop() called automatically hereReactive state
Async exposes the same reactive state interface as ReactivePromise, plus an
"idle" state for the window before the first run() / start():
op.state; // "idle" | "pending" | "fulfilled" | "rejected"
op.value; // resolved value (T | undefined)
op.reason; // rejection reason (E | undefined)
op.result; // value if fulfilled, reason if rejected, undefined otherwise
op.pending; // true only while a run is in flightUnlike a bare ReactivePromise (which wraps an already in-flight promise and so
starts pending), an Async is a lazy runner: with no request yet it starts
idle and op.pending is false until you trigger a run. Drive loading UI off
op.pending and it stays quiet until then.
All properties are reactive — reading them inside an effect or computed subscribes to changes.
Callable signal
An Async instance is also callable as a signal. op() returns op.result and tracks it as a reactive dependency:
import { effect } from "elements-kit/signals";
effect(() => {
const result = op(); // undefined while pending, T when fulfilled, E when rejected
console.log(result);
});This makes Async composable with computed and templates.
Awaitable
Async implements .then, .catch, and .finally, so you can await it directly:
const op = async(() => Promise.resolve(123)).start();
const value = await op; // 123Reactive reruns
Read signals inside the async function to make it re-execute when they change. Only signal reads before the first await are tracked.
import { } from "elements-kit/signals";
import { } from "elements-kit/utilities/async";
const = (1);
const = (() =>
(`https://jsonplaceholder.typicode.com/todos/${()}`) // tracked
.(() => .()),
).(); // re-fetches automatically when id changes
(2); // triggers a new fetchSignals aren't tracked after an await
// ❌ Not reactive — id() is read after an await
const op = async(async () => {
await someAsyncSetup();
const currentId = id(); // not tracked
});
// ✅ Reactive — id() is read before any await
const op = async(async () => {
const currentId = id(); // tracked
await fetch(`/todos/${currentId}`);
});run() is untracked — signals inside the fn do not trigger re-runs. To get reactive reruns with explicit parameters, wrap it in an external effect:
import { effect, signal } from "elements-kit/signals";
const todoId = signal(1);
effect(() => {
fetchTodo.run(todoId()); // re-fetches when todoId changes (tracked by outer effect)
});Cleanups
Register cleanup logic inside your async function using onCleanup. It runs when stop() is called or when start() re-runs due to a signal change:
import { onCleanup } from "elements-kit/signals";
const query = async((id: number) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
return fetch(`/api/todos/${id}`, { signal: controller.signal }).then((r) =>
r.json(),
);
}).start();onCleanup also works inside run() — the cleanup fires when stop() is called or when the next run() replaces it.
See also
- Promise — the underlying
ComputedPromise/ReactivePromiseprimitive. - Data fetching — full recipe composing retry, online, window-focus.
- Signals —
onCleanup,untracked.