---
id: "packages/active-state/async-work"
title: "Async work"
description: "Operations with a policy, typed outcomes and cancellation, so a late answer can never overwrite newer state."
url: "/packages/active-state/async-work"
locale: "en"
revision: "1eb425567b95bda8"
---

# Async work

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.

**Example: A search that a late answer cannot overwrite**

Client: Runs entirely in your browser. Source: `examples/state/search-operation.tsx`.

```tsx
// 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-wins` | supersedes the running call; its signal aborts and it cannot commit |
| `drop-while-running` | is dropped: it ends `cancelled('dropped')` at once |
| `queue` | waits; calls run one at a time, in order |

## Committing state

```ts
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.status` | 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-disposed`, `dropped` or `transaction-reverted` |

`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](/packages/active-state/api)).
