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 plannedexists .
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 (5)
Really like how small the surface is, that's what makes it actually forkable. One thing I'd put high on the roadmap, right next to refetchOnWindowFocus, is cache eviction. With reactive keys spinning up a fresh entry every time the id changes, a session that stays open a while can quietly pile up cached results that nothing ever clears, and TanStack's gcTime exists pretty much for that exact pain. Even a simple eviction timer would keep the "switch routes for an hour" case from slowly growing memory. Curious whether you're leaning toward a gcTime-style timer or counting references against the injection context.
Thanks! And great eye on GC - this one's actually already in, it just isn't surfaced in the article (my bad).
It's both of the things you mentioned, combined - basically TanStack's model:
injectQueryadds one when it runs and removes it on injection-context teardown (or when a reactive key changes and the old entry is abandoned);gcTimetimer (default 5 min). If nothing re-observes it before it fires, it's evicted from the cache; if you navigate back in time, the timer is cleared and the entry is reused.So the "switch routes for an hour" case stays bounded - each abandoned entry is collected ~
gcTimeafter it loses its last observer.gcTimeis configurable per-query, and globally viawithDefaultOptions.refetchOnWindowFocusthough - that one's genuinely not there yet, high on the list. Really appreciate the thoughtful read 🙏Great implementation. Angular Signals simplify local state, but production apps still need shared caching, request deduplication, and cache invalidation. A lightweight, RxJS-native approach fills that gap nicely.
Thanks 🙏 Yeah, shared cache + dedup + invalidation without leaving the RxJS/HttpClient world is exactly the gap I was aiming at.
Seeing Angular adopt signal-based data fetching natively with httpResource is a massive step forward, effectively bringing that familiar TanStack Query developer experience directly into the core framework. Managing server state and caching efficiently is always a huge part of building scalable applications, regardless of which frontend ecosystem you are working in. If you ever want to see how similar data-fetching and caching patterns are handled in a production-ready Next.js and Supabase environment, check out our SaaS boilerplate at publiflow.vip. It really helps cut down the initial setup time when you need to get a new project off the ground quickly.