v0.1.0 · Angular 21 · MIT

Server state for Angular,
built on signals.

Queries, mutations and caching with no observer layer underneath — the cache entry is a signal. Plus the thing every app hand-rolls: control over what happens when the same mutation fires twice.

Read the docs
  • 34 kBunpacked
  • 1runtime dep
  • 0breaking changes
One button, clicked five times

Four strategies. Four outcomes. One option.

mutate()
'merge'mergeMap
12345
'concat'concatMap
12345
'switch'switchMap
××××5
'exhaust'exhaustMap
1

Mutation concurrency

The overlap problem, solved by declaration

A submit button gets double-clicked. An autosave fires while the last one is still in flight. Normally you reach for a disabled flag, a debounce, or a stray AbortController. Here it's one option, with semantics borrowed from RxJS flattening operators.

'merge'mergeMap

Every call runs, in parallel.

Use forIndependent writes — analytics events, unrelated records.

'concat'concatMap

Queued, and kept in order.

Use forOrder-dependent steps, or a queue the server must receive in sequence.

'switch'switchMap

Latest wins, earlier discarded.

Use forAutosave and draft syncing, where only the newest value matters.

'exhaust'exhaustMap

First wins, the rest ignored.

Use forSubmit and checkout buttons. Double-clicks stop being your problem.

// Double-click proof. No disabled flag, no debounce.
const submit = createMutation({
  mutationFn: (order: Order) => api.place(order),
  concurrencyStrategy: 'exhaust',
});

// Autosave — only the newest draft survives.
const autosave = createMutation({
  mutationFn: (draft: Draft) => api.save(draft),
  concurrencyStrategy: 'switch',
});

Playground

Try it. Click the line to fire a call.

Add calls, drag the request duration, and switch strategy — the timeline recomputes with the same rules the library uses at runtime. Click a red marker to remove it.

  1. Pick a strategy below.
  2. Click the timeline to fire a call — or press Add call.
  3. Drag the slider to change how long each request takes.
mutate()5 calls
'exhaust'exhaustMap
145
  • 1 completed — your app gets this result
  • × cancelled in flight
  • never sent
5 calls3 reached the server3 produced a result2 never left the client.
const save = createMutation({
  mutationFn: (input) => api.save(input),
  concurrencyStrategy: 'exhaust',
});

Correctness

Three cache bugs, found and fixed

0.1.0 was mostly repair work. These were real defects in earlier versions, and they're the reason the fetch core was rewritten.

  1. A slow response could overwrite fresh data

    If a refetch began while an earlier request was still in flight, whichever settled last won — so an older payload could silently replace newer cached data. Every fetch now carries a generation, and only the newest is allowed to commit.

  2. The same key fired duplicate requests

    Two components reading the same query key issued two network calls despite sharing a cache entry. Concurrent fetches for a key now share one in-flight promise.

  3. Signal queries diverged from the cache

    createSignalQuery kept a private copy of state rather than the shared entry, so setQueryData() and invalidation could silently fail to reach it. It now reads and writes the real entry.


Honest comparison

Where TanStack Query is still the better choice

TanStack Query is the reference implementation for this problem, and for most teams it remains the right answer. Here is the actual difference, including everything it does that this doesn't.

Capabilityng-signal-queryTanStack Query
Reactivity modelSignals all the way down — the cache entry is a WritableSignalFramework-agnostic core with QueryObserver + notifyManager, signals bound at the edge
Frameworks supportedAngular onlyReact, Vue, Solid, Svelte, Angular
Mutation concurrencymerge · concat · switch · exhaustscope queues same-scope mutations; no switch or exhaust
Request cancellationAbortSignal passed to every fetcherYes
Retry with backoffOpt-in, defaults to offOn by default
enabled / conditional queriesNot yetYes
select, placeholderData, isFetchingNot yetYes
Persistence and offline modeNot yetYes
DevtoolsBasic inspector componentMature, dedicated package
Production track recordReleased 2026. Early.Years, across four frameworks
Install size34 kB unpacked, tslib onlyLarger — core plus adapter

Verified against TanStack Query v5 as of August 2026. Their Angular adapter is published as @tanstack/angular-query-experimental and its docs advise pinning an exact version, since breaking changes ship in minor and patch releases.


Quick start

Three things worth knowing

A query

Pass the signal through so superseded requests are actually cancelled.

const users = createQuery({
  key: ['users'],
  fetcher: ({ signal }) =>
    fetch('/api/users', { signal }).then(r => r.json()),
  staleTime: 30_000,
});

// users.data() · users.isLoading() · users.refetch()

A reactive query

Reads signals inside the factory; re-runs when the key changes.

const userId = signal(1);

const user = createSignalQuery(() => ({
  key: ['user', userId()],
  fetcher: ({ signal }) =>
    fetch(`/api/users/${userId()}`, { signal }).then(r => r.json()),
}));

userId.set(2); // aborts the previous request, fetches the new key

A mutation

Optimistic update with automatic rollback if the server rejects it.

const addTodo = createMutation({
  mutationFn: (text: string) => api.create(text),
  concurrencyStrategy: 'concat',
  optimisticUpdate: (text) =>
    client.setQueryData<Todo[]>(['todos'], (old) => [
      ...(old ?? []),
      { id: 'tmp', text },
    ]),
  invalidateQueries: [['todos']],
});