Async work
Operations with a policy, typed outcomes and cancellation, so a late answer can never overwrite newer state.
@workspace/active-state 1.0.1Developmentunreleased, in development in Foundationbaseline ce081dc3
An Operation owns work that takes time: a request, a computation, anything that resolves later.
It decides what happens when a new call arrives while one is running, and it only lets a call change
state through ctx.commit, so a call that lost its turn cannot write.
A search that a late answer cannot overwriteClient
0 found
// apps/docs/examples/state/search-operation.tsx'use client';
import {useState} from 'react';
import { Operation, Scope, Store, useStore, type Outcome,} from '@workspace/active-state/react';import {Input} from '@workspace/active-ui/components/primitives/input';import {Label} from '@workspace/active-ui/components/primitives/label';
const CITIES = [ 'Amsterdam', 'Antwerp', 'Athens', 'Berlin', 'Bern', 'Bratislava', 'Brussels', 'Budapest', 'Copenhagen', 'Dublin',];
/** * A stand-in for a request: it answers after a delay that is longer for a * shorter query, so an earlier query answers after a later one. */function lookUp(query: string, signal: AbortSignal): Promise<string[]> { return new Promise((resolve, reject) => { const timer = setTimeout( () => resolve( CITIES.filter((city) => city.toLowerCase().startsWith(query.toLowerCase()), ), ), 900 - Math.min(query.length, 4) * 200, ); signal.addEventListener('abort', () => { clearTimeout(timer); reject(signal.reason); }); });}
const scope = new Scope();const results = new Store<string[]>([]);const log = new Store<string[]>([]);const search = new Operation<string, string[]>({ scope, policy: 'latest-wins', run: async (query, ctx) => { const found = await lookUp(query, ctx.signal); // The only way to change state; refused once a newer query superseded this one. ctx.commit(() => results.setState(found)); return found; },});
function describe(query: string, outcome: Outcome<string[]>): string { if (outcome.status === 'succeeded') return `"${query}": committed ${outcome.value.length} result(s)`; if (outcome.status === 'superseded') return `"${query}": superseded by #${outcome.by}, nothing committed`; if (outcome.status === 'cancelled') return `"${query}": cancelled (${outcome.reason})`; return `"${query}": failed`;}
/** An Operation with the latest-wins policy: a late answer cannot overwrite a newer one. */export default function SearchOperation() { const [query, setQuery] = useState(''); const found = useStore(results); const status = useStore(search.status); const lines = useStore(log); return ( <div className="flex flex-col gap-3"> <div className="flex flex-col gap-1.5"> <Label htmlFor="state-search">City</Label> <Input id="state-search" value={query} placeholder="Type quickly: B, Br, Bra…" onChange={(event) => { const next = event.target.value; setQuery(next); const invocation = search.invoke(next); void invocation.outcome.then((outcome) => log.update((list) => [`#${invocation.id} ${describe(next, outcome)}`, ...list].slice( 0, 6, ), ), ); }} /> </div> <p className="text-sm" aria-live="polite"> {status.phase === 'running' ? 'Searching…' : `${found.length} found`} {found.length > 0 ? `: ${found.join(', ')}` : ''} </p> <ol className="text-muted-foreground font-mono text-xs" data-state-log> {lines.map((line) => ( <li key={line}>{line}</li> ))} </ol> </div> );}Policies#
| Policy | A new call while one runs |
|---|---|
latest- | supersedes the running call; its signal aborts and it cannot commit |
drop- | is dropped: it ends cancelled('dropped') at once |
queue | waits; calls run one at a time, in order |
Committing state#
import {Operation, Scope, Store} from '@workspace/active-state';
const scope = new Scope();const results = new Store<string[]>([]);
const search = new Operation<string, string[]>({ scope, policy: 'latest-wins', run: async (query, ctx) => { const response = await fetch(`/api/search?q=${query}`, { signal: ctx.signal, }); const found = (await response.json()) as string[]; ctx.commit(() => results.setState(found)); // refused once superseded return found; },});ctx.commit runs its callback as a transaction, and only while the call still has authority. A
direct write after an await is not protected: always write through ctx.commit.
Outcomes, not exceptions#
invoke() returns an Invocation with an id, a status and an outcome promise that never
rejects:
outcome. | Meaning |
|---|---|
succeeded | value is the result of run |
failed | error is what run threw or rejected with |
superseded | by is the id of the call that replaced it |
cancelled | reason: requested, owner-, dropped or transaction- |
operation.status is a source of {phase, running, queued, last}; read it with useStore to show
a busy state.
Ending the work#
Disposing the Scope (or the operation) cancels every call with owner-disposed and aborts their
signals. A component that owns a model for its own lifetime can use useScoped (experimental; see
the API reference).