Angular has signals now - and as of 19.2, even a signal-based way to fetch: httpResource, built on the resource primitive. For a single component pulling data reactively it's genuinely nice - value(), status(), error(), and an auto-refetch when its request signal changes.
But the moment a second component touches the same data, you hit the line httpResource and resource deliberately don't cross: no shared cache, no deduplication, no cross-component invalidation, no mutation lifecycle, no retries / polling / staleTime. Each resource fetches on its own. So you either hand-roll a cache - or reach for a query library.
That cache/query layer is exactly what TanStack Query provides - and its Angular adapter is excellent and (yes) already signal-based. query.data(), query.isPending() are signals; no manual subscriptions. So let me get the obvious question out of the way:
Why build another one?
Three honest reasons:
-
It speaks Observables natively.
queryFnreturns anObservableor aPromise, soHttpClientdrops straight in - nofirstValueFrom/lastValueFromdance. TanStack's core is Promise-first. - It's tiny and from-scratch. No framework-agnostic core engine underneath - it's Angular Signals all the way down. Small surface, easy to read, easy to fork.
- I wanted to actually understand the problem by building it - and it turned out usable enough to share.
So: not a TanStack killer. A smaller, RxJS-friendly, signal-native take. It's called ngx-signal-query, it's MIT, Angular 19+, and just hit 1.0.0.
Up front: the surface is intentionally small. I'll be honest about what it doesn't do yet near the end - that part matters.
The problem, concretely
Say you reach for httpResource - one component is sorted:
readonly todos = httpResource<Todo[]>(() => '/api/todos');
// todos.value(), todos.isLoading(), todos.error() - all signals
Now a second component needs the same todos. It spins up its own httpResource → a second network request. Then a mutation adds a todo somewhere else → nothing tells the first component to refresh its list. Add retries on a flaky network, polling, an optimistic update with rollback... and you're quietly building a cache by hand.
That's the gap a query library fills: a shared, keyed cache - with deduplication, invalidation, and a mutation lifecycle - sitting on top of whatever does the actual fetching.
What it looks like instead
Install:
npm install ngx-signal-query
Register the client once at the app root:
// app.config.ts
import { provideHttpClient } from '@angular/common/http'
import { provideQueryClient } from 'ngx-signal-query'
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(), provideQueryClient()],
}
Then fetch data straight into signals:
import { Component, inject } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { injectQuery } from 'ngx-signal-query'
@Component({
selector: 'app-todos',
template: `
@if (todos.isPending()) {
<p>Loading...</p>
} @else if (todos.isError()) {
<p>Something went wrong.</p>
} @else {
<ul>
@for (todo of todos.data(); track todo.id) {
<li>{{ todo.title }}</li>
}
</ul>
}
<button (click)="todos.refetch()">Refetch</button>
`,
})
export class TodosComponent {
private readonly http = inject(HttpClient)
readonly todos = injectQuery(() => ({
queryKey: ['todos'],
queryFn: () => this.http.get<Todo[]>('/api/todos'),
}))
}
That's it. todos.data(), todos.status(), todos.isPending(), todos.isFetching(), todos.error() - all signals. No subscription, no async pipe, no cleanup. The query is tied to the component's injection context and cleans itself up on destroy.
queryFn accepts an Observable or a Promise, so fetch, HttpClient, anything works.
Reactive query keys
Because injectQuery takes a function that's read reactively, you can build the key from signals - and the query automatically switches when they change:
readonly id = input.required<number>();
readonly todo = injectQuery(() => ({
queryKey: ['todo', this.id()], // ← reads the input signal
queryFn: () => this.http.get<Todo>(`/api/todos/${this.id()}`),
}));
Change id (a route param, an input, anything) → it refetches the new one, and the previous result stays cached under its own key. No manual switchMap.
Mutations + optimistic updates
Writes get the same treatment, plus a familiar lifecycle:
import { injectMutation, injectQueryClient } from 'ngx-signal-query';
const client = injectQueryClient();
readonly addTodo = injectMutation(() => ({
mutationFn: (title: string) => this.http.post<Todo>('/api/todos', { title }),
// optimistic update
onMutate: (title) => {
const previous = client.getQueryData<Todo[]>(['todos']) ?? [];
client.setQueryData<Todo[]>(['todos'], (prev = []) => [
...prev,
{ id: Date.now(), title },
]);
return { previous }; // becomes `context` below
},
onError: (_err, _title, context) => {
client.setQueryData(['todos'], context?.previous); // rollback
},
onSuccess: () => client.invalidateQueries({ queryKey: ['todos'] }),
}));
<button (click)="addTodo.mutate('Buy milk')" [disabled]="addTodo.isPending()">
Add
</button>
Hooks fire in order: onMutate → onSuccess | onError → onSettled, and whatever onMutate returns is passed as context for rollback. Same mental model as TanStack Query.
The stuff you'd otherwise reimplement
-
Caching & deduplication - two components using
queryKey: ['todos']share one cache entry and one in-flight request. -
Retries -
retry: 3orretry: (failureCount, err) => ..., with configurableretryDelay. -
Polling -
refetchInterval: 5000, or a function of the current state (e.g. stop polling once it errors). -
Freshness -
staleTimecontrols when cached data is considered stale and refetched in the background. -
Global indicators -
injectIsFetching()/injectIsMutating()return signals with the count of active queries/mutations, for an app-wide spinner. -
Imperative cache access -
injectQueryClient()gives yougetQueryData,setQueryData,invalidateQueries,cancelQueries,removeQueries.
Signals compose with everything else
Like any signal-based query library, the result composes with the rest of Angular's reactivity for free:
readonly todos = injectQuery(() => ({ queryKey: ['todos'], queryFn: ... }));
// derive with computed, no extra wiring
readonly openCount = computed(() => this.todos.data()?.filter(t => !t.done).length ?? 0);
It's pull-based, plays nicely with OnPush, and there's no subscription lifecycle to think about. The query result is just state - read it wherever, derive from it, done.
What it does NOT do (yet)
This is 1.0.0 with a deliberately focused core. To set expectations honestly, here's what TanStack Query has that this doesn't - and what's on my radar:
-
refetchOnWindowFocus/refetchOnReconnect- refetching when the tab regains focus or the network comes back. High on the list. -
Infinite / paginated queries (
injectInfiniteQuery) - not there yet. - Devtools - no inspector panel.
- SSR / hydration - no server-state transfer.
-
Cache persistence - no
localStorage/IndexedDBadapter. -
select/placeholderData/ structural sharing - onlyinitialDatafor now. - A real docs site - for now it's the README + this article; an ng-doc site is planned.
None of these are hard "no"s - they're "not yet." What gets built next depends a lot on what people actually ask for.
Try it
npm install ngx-signal-query
- 📦 npm: ngx-signal-query
- 🧑💻 GitHub: dhutaryan/ngx-signal-query
If you try it, I'd genuinely love feedback - bug reports, "I wish it did X", or just a ⭐ if the idea resonates. Issues and PRs are open. The roadmap above is negotiable; tell me what you'd reach for first.
Built it because I wanted it. Shipping it because maybe you do too. 🚀
Top comments (0)