DEV Community

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

Posted on

TanStack Query style caching, the Angular-native way

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.

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 (0)