DEV Community

Cover image for TanStack Query style caching, the Angular-native way
Dzmitry Hutaryan
Dzmitry Hutaryan

Posted on • Edited on

TanStack Query style caching, the Angular-native way

Native RxJS support and high forkability

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:

  1. It speaks Observables natively. queryFn returns an Observable or a Promise, so HttpClient drops straight in - no firstValueFrom / lastValueFrom dance. TanStack's core is Promise-first.
  2. 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.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()],
}
Enter fullscreen mode Exit fullscreen mode

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'),
  }))
}
Enter fullscreen mode Exit fullscreen mode

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()}`),
}));
Enter fullscreen mode Exit fullscreen mode

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'] }),
}));
Enter fullscreen mode Exit fullscreen mode
<button (click)="addTodo.mutate('Buy milk')" [disabled]="addTodo.isPending()">
  Add
</button>
Enter fullscreen mode Exit fullscreen mode

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: 3 or retry: (failureCount, err) => ..., with configurable retryDelay.
  • Polling - refetchInterval: 5000, or a function of the current state (e.g. stop polling once it errors).
  • Freshness - staleTime controls 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 you getQueryData, 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);
Enter fullscreen mode Exit fullscreen mode

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/IndexedDB adapter.
  • select / placeholderData / structural sharing - only initialData for now.
  • A real docs site - for now it's the README + this article; an ng-doc site is planned exists .

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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
nazar-boyko profile image
Nazar Boyko

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.

Collapse
 
dhutaryan profile image
Dzmitry Hutaryan

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:

  • each query ref-counts its observers; injectQuery adds one when it runs and removes it on injection-context teardown (or when a reactive key changes and the old entry is abandoned);
  • when the observer count hits zero, the query cancels any in-flight fetch and starts a gcTime timer (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 ~gcTime after it loses its last observer. gcTime is configurable per-query, and globally via withDefaultOptions.

refetchOnWindowFocus though - that one's genuinely not there yet, high on the list. Really appreciate the thoughtful read 🙏

Collapse
 
mariaandrew profile image
Maria andrew

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.

Collapse
 
dhutaryan profile image
Dzmitry Hutaryan • Edited

Thanks 🙏 Yeah, shared cache + dedup + invalidation without leaving the RxJS/HttpClient world is exactly the gap I was aiming at.

Collapse
 
publiflow profile image
PubliFlow

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.