'merge'mergeMapEvery call runs, in parallel.
Use forIndependent writes — analytics events, unrelated records.
v0.1.0 · Angular 21 · MIT
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.
Four strategies. Four outcomes. One option.
'merge'mergeMap'concat'concatMap'switch'switchMap'exhaust'exhaustMapMutation concurrency
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'mergeMapEvery call runs, in parallel.
Use forIndependent writes — analytics events, unrelated records.
'concat'concatMapQueued, and kept in order.
Use forOrder-dependent steps, or a queue the server must receive in sequence.
'switch'switchMapLatest wins, earlier discarded.
Use forAutosave and draft syncing, where only the newest value matters.
'exhaust'exhaustMapFirst 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
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.
mutate()5 calls'exhaust'exhaustMapconst save = createMutation({
mutationFn: (input) => api.save(input),
concurrencyStrategy: 'exhaust',
});Correctness
0.1.0 was mostly repair work. These were real defects in earlier versions, and they're the reason the fetch core was rewritten.
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.
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.
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
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.
| Capability | ng-signal-query | TanStack Query |
|---|---|---|
| Reactivity model | Signals all the way down — the cache entry is a WritableSignal | Framework-agnostic core with QueryObserver + notifyManager, signals bound at the edge |
| Frameworks supported | Angular only | React, Vue, Solid, Svelte, Angular |
| Mutation concurrency | merge · concat · switch · exhaust | scope queues same-scope mutations; no switch or exhaust |
| Request cancellation | AbortSignal passed to every fetcher | Yes |
| Retry with backoff | Opt-in, defaults to off | On by default |
| enabled / conditional queries | Not yet | Yes |
| select, placeholderData, isFetching | Not yet | Yes |
| Persistence and offline mode | Not yet | Yes |
| Devtools | Basic inspector component | Mature, dedicated package |
| Production track record | Released 2026. Early. | Years, across four frameworks |
| Install size | 34 kB unpacked, tslib only | Larger — 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
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()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 keyOptimistic 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']],
});