Introducing TanStack Query

Angular 16 brought signals into the framework — a reactive primitive designed by the core team to push Angular forward. Signals enable fine-grained rendering updates and open the door to a future where change detection no longer depends on ngZone. They also give library authors across the open source community a fresh mechanism for reacting to value changes in components, directives, and services.

Roughly seven months later, the Angular ecosystem has embraced this direction enthusiastically. New tools and libraries built on signals continue to appear at a rapid pace.

Notable examples that come to mind are ngrx/signals, as well as signalSlice and combine from the ngxtension project.

Signals do more than spark innovation within the existing Angular ecosystem — they also make it possible to port powerful solutions from other frameworks. Their way of notifying and reacting to value changes is considerably less complex than RxJs, which lowers the barrier for bringing proven libraries over to Angular.

One such port, and the one I am currently most enthusiastic about, is the official adapter from Arnoud that brings TanStack Query to Angular.

In this article I will walk you through TanStack Query and hopefully convince you to give TanStack Query & Angular a shot.

What exactly is TanStack Query?

TanStack Query is a data-fetching library that started in the React world. It is an opinionated solution for fetching, caching, synchronizing and updating server state in web applications.

Anyone who has worked on larger Angular applications has likely noticed a gap: while the framework ships with HttpClient for remote data, it does not prescribe how the state of those requests should be managed. To fill that void, developers often build custom solutions — perhaps global NgRx or NGXS stores for server data, entity collections, and manual request triggering. Some even end up crafting their own meta-server-state-management layer to bring structure.

The issue is that NgRx, NGXS, and Elf are powerful state management libraries, but they were designed primarily with client state in mind. They were not explicitly built around the peculiarities of asynchronous server state.

Understanding server state

The TanStack Query team offers a clear definition of server state. Their key points are:

  • It resides remotely, in a location you neither own nor control
  • Fetching and updating require asynchronous APIs
  • Ownership is shared — others can change the data without your awareness
  • Without diligence, it can become stale inside your application

This framing makes sense. In most applications, data lives in a remote database rather than in the browser. Accessing or changing it means communicating over API calls, which are inherently asynchronous. Other actors frequently have access to the same underlying records, so even after you retrieve fresh data from the API, someone else may have already modified the source.

So much for this.http.post — managing server state turns out to be a far more demanding task.

The TanStack team also highlights several difficulties that emerge once you accept this reality:

  • Caching, arguably one of the hardest challenges in programming
  • Deduplicating multiple requests for identical data into one
  • Refreshing data that has gone stale in the background
  • Detecting when data has become outdated
  • Propagating updates to data as fast as possible
  • Optimizing performance through pagination and lazy loading
  • Handling memory and garbage collection for server state
  • Using structural sharing to memoize query results

Reading through that list makes me want to raise the alarm on complexity. I enjoy a good technical problem — I built spartan/ui for that reason — but this set of concerns looks like a serious headache.

It becomes even less appealing when you realise an established, battle-tested server state library already exists. TanStack Query promises to "[t]oss out that granular state management, manual refetching and endless bowls of async-spaghetti code [and] gives you declarative, always-up-to-date auto-managed queries and mutations that directly improve both your developer and user experiences."

I appreciate declarative answers to difficult problems — especially when they come from people far smarter than myself who have anticipated the edge cases. Such solutions let us build on years of hard work so we can focus on creating our own applications.

I love TanStack Query!

The Core Role of Queries

TanStack Query, as its name makes clear, centers entirely around the notion of queries. The library assumes responsibility for all the intricate work of retrieving data for those queries, keeping that data current, and alerting you whenever the information becomes stale — along with a range of other capabilities.

Consider a basic example that retrieves details about the TanStack Query GitHub repository:

import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { CommonModule } from '@angular/common'
import { injectQuery } from '@tanstack/angular-query-experimental'
import { lastValueFrom } from 'rxjs'

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: 'simple-example',
  standalone: true,
  template: `
    @if (query.isPending()) {
      Loading...
    }
    @if (query.error()) {
      An error has occurred: {{ query.error().message }}
    }
    @if (query.data(); as data) {
      <h1>{{ data.name }}</h1>
      <p>{{ data.description }}</p>
      <strong>👀 {{ data.subscribers_count }}</strong>
      <strong>✨ {{ data.stargazers_count }}</strong>
      <strong>🍴 {{ data.forks_count }}</strong>
    }
  `,
})
export class SimpleExampleComponent {
  http = inject(HttpClient)

  query = injectQuery(() => ({
    queryKey: ['repoData'],
    queryFn: () =>
      lastValueFrom(
        this.http.get<Response>('https://api.github.com/repos/tanstack/query'),
      ),
  }))
}
Enter fullscreen mode Exit fullscreen mode

That snippet is sufficient to retrieve the data, store it effectively in cache, and offload every server-state challenge discussed earlier.

All we supply to TanStack Query via the injectQuery function is a distinctive identifier called the queryKey for the data we're after, along with instructions on how to obtain it through a queryFn.

With that foundation laid, let's investigate more closely what drives TanStack Query (no pun regarding change detection intended).

Revisiting the Query Concept

According to the documentation:
A query is a declarative dependency on an asynchronous source of data that is tied to a unique key. A query can be used with any Promise based method (including GET and POST methods) to fetch data from a server.

This clarifies the two arguments we observe being supplied to injectQuery in our illustration:

queryKey: ['repoData'],
queryFn: () => lastValueFrom(this.http.get<Response>('https://api.github.com/repos/tanstack/query')),
Enter fullscreen mode Exit fullscreen mode

The ['repoData'] array functions as the unique query key. This key maps to a "container" that stores all information regarding the state and payload of our query. If you're curious why this key takes the form of an array rather than a simple string, hold that thought — we'll elaborate shortly.

To refresh our query's data, we need a promised based method for server communication, namely the queryFn. In Angular, server interactions happen via the HttpClient. Since the HttpClient produces an Observable, we employ lastValueFrom to convert our server client interaction into a promise-based one:

() => lastValueFrom(this.http.get<Response>('https://api.github.com/repos/tanstack/query'))

The result object returned from the injectQuery function encompasses every piece of query-related information you might require for rendering templates or any alternative data usage, as demonstrated in our example's template:

@if (query.isPending()) {
  Loading...
}
@if (query.error()) {
  An error has occurred: {{ query.error().message }}
}
@if (query.data(); as data) {
  <h1>{{ data.name }}</h1>
  <p>{{ data.description }}</p>
  <strong>👀 {{ data.subscribers_count }}</strong>
  <strong>✨ {{ data.stargazers_count }}</strong>
  <strong>🍴 {{ data.forks_count }}</strong>
}
Enter fullscreen mode Exit fullscreen mode

The query object exposes several critical states you must understand to work effectively. At any single moment, a query exists in exactly one of these states:

  1. isPending or status === 'pending' - The query has not yet acquired any data
  2. isError or status === 'error' - The query has run into an error
  3. isSuccess or status === 'success' - The query completed successfully and data is accessible

Beyond these primary statuses, additional details become available depending on where the query stands:

  1. error - When the query is in an isError state, the specific error can be accessed via the error property.
  2. data - When the query is in an isSuccess state, the actual data can be accessed via the data property.
  3. isFetching - Irrespective of state, if the query is fetching at any point (even during background refetches) the isFetching flag will be true.

Understanding the Array-Based queryKey

We're on solid ground now: injectQuery offers a streamlined approach to holding server-state for a given query key along with its associated promise-based server-client interaction. But why does the key have to be an array? Wouldn't a straightforward string suffice?

When uncertain, consult TanStack Query's outstanding documentation. Here's what it says:

At its core, TanStack Query manages query caching for you based on query keys. Query keys have to be an Array at the top level, and can be as simple as an Array with a single string, or as complex as an array of many strings and nested objects. As long as the query key is serializable, and unique to the query's data, you can use it!

The requirements for uniqueness and serializability offer some hints, but they alone don't fully explain the choice of an Array over strings. Let's examine a few scenarios to grasp the reasoning.

For the most basic situations — like generic lists or non-hierarchical resources — a plain string might do the job.

// A list of todos
injectQuery({ queryKey: ['todos'], ... })

// Something else, whatever!
injectQuery({ queryKey: ['something', 'special'], ... })
Enter fullscreen mode Exit fullscreen mode

Yet, sticking with arrays pays off considerably! In practice, our queries often demand extra details to fully distinguish their data. That's precisely why arrays serve as our query keys. They allow us to merge strings with any number of serializable objects to characterize our data:

// An individual todo
injectQuery({ queryKey: ['todo', 5], ... })

// An individual todo in a "preview" format
injectQuery({ queryKey: ['todo', 5, { preview: true }], ...})

// A list of todos that are "done"
injectQuery({ queryKey: ['todos', { type: 'done' }], ... })
Enter fullscreen mode Exit fullscreen mode

As illustrated, when dealing with hierarchical or nested resources, providing an ID, index, or another primitive to uniquely identify an item — or handling queries that depend on supplemental search parameters — the array approach with its built-in hierarchy really shines.

A crucial point to keep in mind: since query keys uniquely characterize the data they fetch, they must consistently incorporate any variables from your query function that may change.

The Rationale for Function-Based Options

The short version: we can regard the function that delivers our options as an effect in the context of Angular's signals. This implies the options refresh automatically whenever any of the signals involved in constructing them undergo a change. Our query begins to respond to the signals we use to define it.

Let's trace how we arrived at this signal-driven methodology.

TanStack Query's React Origins

TanStack Query first appeared as a data fetching library tailored for React.

In the React ecosystem, everything boils down to components. There's no concept of services or directives; your app ultimately consists solely of components. React triggers a re-render of a component whenever its props — values passed directly into it — or its local state, established through useState, undergo modification.

React lacks a dependency injection mechanism analogous to Angular's. Instead, it leans on a notion called hooks, such as useQuery. These hooks are functions that get re-executed each time the component invoking them is re-rendered.

TanStack Query stores all of its server-side state externally to the React application.

import {
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'
// Create a client outside of the React App
const queryClient = new QueryClient()

function App() {
  return (
    // Provide the client to your App
    <QueryClientProvider client={queryClient}>
      <Todos />
    </QueryClientProvider>
  )
}
Enter fullscreen mode Exit fullscreen mode

It operates in the same manner within Angular. To get our earlier example functioning, we need to instantiate the QueryClient as a global object and supply it during the bootstrap phase of our Angular application:

import { provideHttpClient } from '@angular/common/http'
import {
  provideAngularQuery,
  QueryClient,
} from '@tanstack/angular-query-experimental'

const queryClient = new QueryClient();

bootstrapApplication(AppComponent, {
  providers: [provideHttpClient(), provideAngularQuery(queryClient)],
})
Enter fullscreen mode Exit fullscreen mode

The setup code for integrating TanStack Query with React and Angular appears quite similar. In both cases, TanStack's QueryClient is instantiated outside the core application. Its foundation remains framework-agnostic.

Nevertheless, to leverage the full suite of server-state management advantages TanStack provides, we must link this queryClient to our application.

In React, we achieve this through the useQuery hook, to which we pass our query keys. Hooks undergo re-execution when a component's input props or state change. If a query key relies on props or state, the key shifts accordingly, which in turn relays the modification to the queryClient:

function Todos() {
  const [page, setPage] = useState(0);
  const query = useQuery({ queryKey: ['todos', page], queryFn: getTodos })

  return (
      <ul>{query.data?.map((todo) => <li key={todo.id}>{todo.title}</li>)}</ul>
  )
}
Enter fullscreen mode Exit fullscreen mode

Angular's Reactive Approach

Angular, on the other hand, doesn't treat everything as a component, and there's no notion of a component tree undergoing re-renders. Instead, Angular features its own Change Detection Mechanism built around ngZone. For TanStack Query, this means the straightforward paradigm of "input changes or (non reactive) state changes triggers function call with new parameter, which alters the query key and prompts TanStack Query to perform its operations" no longer applies.

Still, this responsiveness to input or state changes bears a striking resemblance to what effects accomplish with signals. While the particulars are more nuanced, this serves as a solid initial grasp of how signals enable such a seamless port to Angular:

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: 'todos',
  standalone: true,
  template: `
  <ul>
    @for(todo of query.data() ?? []; track todo.id) {
      <li>{todo.title}</li>
    }
  </ul>
  `,
})
export class TodosComponent {
  page = signal(0)
  query = injectQuery(() => ({
    queryKey: ['todos', page()],
    queryFn: () => getTodos(this.page()))
}
Enter fullscreen mode Exit fullscreen mode

Whenever the page variable — a "simple" state value in React or a signal holding state in Angular — changes, the TanStack Query client gets notified of this page shift. The query key, constructed from the page value, is subsequently updated, leading to a refetch of the corresponding data.

As @arnouddv highlighted, there's an additional justification for returning options via a function:

[Returning options from a functions] allows to preserve expressions. JavaScript - like most languages, is eagerly evaluated. The only practical way to preserve expressions is to wrap them in a function.

Which allows code like this:

postQuery = injectQuery(() => ({
    // ...
    enabled: this.postId() > 0
}))
Enter fullscreen mode Exit fullscreen mode

Which means the query will be automatically enabled for you whenever the postId signal value is greater than 0. No need to separately define and pass a computed signal. This works for all the query properties.

If it becomes more complex than a simple comparison or maybe a ternary I would recommend a separate computed for code readability though.

Mutations: Refreshing the client after changing the server

Having covered how queries retrieve and manage asynchronous server state, we now shift our focus to altering that state and keeping the data we display on the client in sync with the backend.

For creating, updating, deleting, or any other server-side operation, TanStack Query provides mutations. These are executed with the injectMutation function. As an example, to add a new todo item on the server, you would write a mutation like this:

@Component({
  template: `
    <div>
      @if (mutation.isPending()) {
        <span>Adding todo...</span>
      } @else if (mutation.isError()) {
        <div>An error occurred: {{ mutation.error()?.message }}</div>
      } @else if (mutation.isSuccess()) {
        <div>Todo added!</div>
      }
      <button (click)="mutation.mutate(1)">Create Todo</button>
    </div>
  `,
})
export class TodosComponent {
  todoService = inject(TodoService)
  mutation = injectMutation(() => ({
    mutationFn: (todoId: number) =>
      lastValueFrom(this.todoService.create(todoId)),
  }))
}
Enter fullscreen mode Exit fullscreen mode

The returned mutation object exposes state flags similar to a query: isIdle, isPending, isError, isSuccess, error, and data.

Additionally, it provides a mutate method, which invokes the mutationFn you supplied in the options of injectMutation. In the example, pressing the Create Todo button kicks off the mutation. You can also pass a payload to this function, such as data collected from a form.

There will be times when you need to clear the state associated with a mutation. Each mutation object comes with a reset method, which you can use to clear its data or error properties.

In contrast to queries, mutations are fairly simple. The complexity often comes afterward — after the server state has changed, you need to ensure the frontend data reflects it. For this, you can provide an onSuccess callback within the injectMutation options. This runs right after a successful mutation and is the ideal spot for updating your data.

There are two primary approaches to update the frontend after a mutation:

  1. Invalidate the query keys influenced by the mutation, and let TanStack Query fetch the updated data. This approach leverages the declarative nature of server-state management, as the keys drive the entire refresh process.
export const injectAddComment = (id: string) => {
  const http = inject(HttpClient);
  const queryClient = injectQueryClient();
  return injectMutation((client) => ({
    mutationFn: (newComment: string) =>
      lastValueFrom(
        http.post(`/posts/${id}/comments`, newComment)
      ),
    // Invalidate and refetch by using the client directly
    onSuccess: () => {
      // ✅ refetch the comments list for our blog post
      queryClient.invalidateQueries({
        queryKey: ['posts', id, 'comments']
      })
    },
  }));
};
Enter fullscreen mode Exit fullscreen mode

Under the hood, TanStack Query applies fuzzy matching when invalidating queries by key. If you have several keys for your comments list, all of them will be invalidated. Only the currently active ones are refetched; the inactive ones are marked as stale and will be refetched upon their next use.

Imagine you have a sorting option for your comments. After adding a new comment, two queries might be sitting in the cache:

['posts', 5, 'comments', { sortBy: ['date', 'asc'] }
['posts', 5, 'comments', { sortBy: ['author', 'desc'] }
Enter fullscreen mode Exit fullscreen mode

Only one of them is currently displayed. The call to invalidateQueries will refetch the one being shown and simply mark the other as stale. That way, your users always see the current data.

  1. Alternatively, if you know exactly which data in the frontend must change, you can set it directly in the query client. While this avoids an extra API call, it should be approached with caution.
export const injectUpdateTitle = (id: string) => {
  const http = inject(HttpClient);
  const queryClient = injectQueryClient();
  return injectMutation((client) => ({
    mutationFn: (newTitle: string) =>
      lastValueFrom(
        http.post(`/posts/${id}`, {
          title: newTitle,
        })
      ),
    // Invalidate and refetch by using the client directly
    onSuccess: (newPost: Post) => {
      // ✅ update detail view directly
      queryClient.setQueryData(['posts', id], newPost)
    },
  }));
};
Enter fullscreen mode Exit fullscreen mode

By setting the data directly in the cache via setQueryData, TanStack Query treats it as if the backend had returned it. Everything subscribing to that query will subsequently update.

Invalidation should generally be your first choice. Direct updates require your frontend to contain more code and often duplicate backend logic. Updating a sorted list directly is an excellent example — how do you know where the mutated entry belongs after an update? It's far more reliable to invalidate the whole list.

Beyond components: Using TanStack Query in directives and services

Since directives are essentially components without templates, you can use the same query and mutation code we've seen so far inside any of your directives.

This actually highlights a crucial point: TanStack Query is indifferent to where you use it. From earlier, we know that server state is managed outside of your Angular application. The only constraint is that you must call the injectQuery function within an injection context.

This, of course, originates from Angular's dependency injection rules. As a class' instantiation always happens in an injection context, it feels quite natural to define a query variable as a class property. If you want to deepen your understanding of Angular's injection context, you'll find this article useful.

Lastly, note that the injectQuery function returns all its tracked state and data as signals. These signals seamlessly integrate with everything you build — your templates, directives, and services alike.

My Approach to Using TanStack Query with Modern Angular

Building Reusable Queries Through Custom Injection Functions

As I began working with TanStack Query in Angular projects, one thing became apparent: I didn't want to duplicate queryKey and queryFn definitions across multiple places. That approach tends to breed inconsistencies and hard-to-trace bugs. What I wanted instead was a mechanism for reusing queries.

Custom injection functions (CIFs) turn out to be the perfect tool for this. You can create a CIF that accepts a params signal to control the query, plus an optional Injector for cases where no injection context exists. If you are interested in the design rationale, Chau's article on injection functions is an excellent deep dive.

Combining these concepts produces a reusable query like this:

import { inject, Injector, runInInjectionContext } from '@angular/core';
import { assertInjector } from 'ngxtension/assert-injector';
import { HttpClient } from '@angular/common/http';
import { injectQuery } from '@tanstack/angular-query-experimental';
import { lastValueFrom } from 'rxjs';
import { todoKeys, ToDo } from './todos.keys';

export const injectTodosQuery = (params: Signal<{ done?: boolean }>,{ injector }: { injector?: Injector } = {}) => {
  injector = assertInjector(injectTodosQuery, injector);
  return runInInjectionContext(injector, () => {
    const http = inject(HttpClient);
    return injectQuery(() => ({
      queryKey: todoKeys.list,
      queryFn: () => lastValueFrom(http.get<ToDo[]>(`todos?done=${!!params().done}`)),
    }));
  });
};
Enter fullscreen mode Exit fullscreen mode

The assertInjector helper from ngxtension works like this:

export function assertInjector(fn: Function, injector?: Injector): Injector {
    // we only call assertInInjectionContext if there is no custom injector
    !injector && assertInInjectionContext(fn);
    // we return the custom injector OR try get the default Injector
    return injector ?? inject(Injector);
}
Enter fullscreen mode Exit fullscreen mode

This query can then be used inside any component, directive, or service. The optional Injector means you can defer the query injection until it's actually required — for example, once Inputs are ready during ngOnInit.

Check the Official Docs and TkDodo's Blog

Beyond these Angular-specific considerations, I make a point of studying and replicating the patterns described in the TanStack documentation, and I often reach for the outstanding blog posts by @tkdodo over at his personal site. So far, any question I've had about TanStack Query has been covered there.

A few takeaways from his writing stand out as particularly vital:

Keep Your Query Keys Tidy

Place your Query Keys alongside the queries they belong to, whether that's inside a feature directory or an Nx library:

- src
  - app
    - features
      - todos
        - todos.keys.ts
        - todos.mutations.ts
        - todos.query.ts
Enter fullscreen mode Exit fullscreen mode

Organize your Query Keys from the most general to the most specific, adding as many intermediate levels of detail as makes sense. Consider this arrangement for a todo list that includes both filterable collections and detail pages:

['todos', 'list', { filters: 'all' }]
['todos', 'list', { filters: 'done' }]
['todos', 'detail', 1]
['todos', 'detail', 2]
Enter fullscreen mode Exit fullscreen mode

Up to this point, we have been writing Query Keys by hand quite often. That approach is error-prone and makes future changes harder, especially if you want to introduce another level of detail into your keys.

Dominik @tkdodo suggests using a Query Key factory for each feature: a small object that contains entries and methods to generate query keys. For the structure above, it might look like this:

const todoKeys = {
  all: ['todos'] as const,
  lists: () => [...todoKeys.all, 'list'] as const,
  list: (filters: string) => [...todoKeys.lists(), { filters }] as const,
  details: () => [...todoKeys.all, 'detail'] as const,
  detail: (id: number) => [...todoKeys.details(), id] as const,
}
Enter fullscreen mode Exit fullscreen mode

Don't just take my word for it — read Dominik's post for a far more comprehensive look.

Key Insights for Handling Mutations

Queries via injectQuery are declarative; mutations via injectMutation are imperative.

TanStack queries run for the most part without direct intervention. We declare their dependencies, and TanStack Query takes care of executing the query right away. It also handles intelligent background refreshes whenever needed. This pattern suits queries well, since the goal is to keep what's shown on screen aligned with the latest backend state.

That same approach wouldn't work for mutations: just picture a new todo popping in every time the user tabs back to their browser. So rather than firing a mutation immediately, TanStack Query hands us a function that we can call whenever we choose to perform the mutation.

That's also the reason mutations don't share state the way injectQuery does. If you call injectQuery in several different components, they all read the same cached result. injectMutation behaves differently: each invocation produces a fresh mutation with its own isolated state, triggerable through its mutate-method.

Watch Out for await-ed Promises

TanStack Query waits on any Promises returned from mutation callbacks. Since invalidateQueries returns a Promise, your mutation will remain in a loading state while the affected queries refresh if you remember to return the result of invalidateQueries from the callback!

Choosing Between mutate and injectMutation Callbacks

Callbacks can be attached both to injectMutation and to mutate itself. Note that callbacks defined on injectMutation run before those on mutate. Also, callbacks on mutate may never execute if the owning component, directive, or service gets destroyed before the mutation completes.

It's wise to carve out separate responsibilities for each set of callbacks:

  • Keep logic that must always run — like query invalidation — inside the injectMutation callbacks.
  • Delegate UI concerns such as redirects or toast notifications to the mutate callbacks. If the user leaves the page mid-mutation, those callbacks intentionally won't run.
// always, declared in the Custom Injection Function (CIF)
const injectUpdateTodo = () => {
  const queryClient = injectQueryClient();
  return injectMutation({
    mutationFn: updateTodo,
    // ✅ always invalidate the todo list
    onSuccess: () => {
      queryClient.invalidateQueries({
        queryKey: ['todos', 'list']
      })
    },
  })
}

// in the component
private updateTodo = injectUpdateTodo();
...
updateTodo.mutate(
  { title: 'newTitle' },
  // ✅ only redirect if we're still on the detail page
  // when the mutation finishes
  { onSuccess: () => router.navigate(['todos']) }
)
Enter fullscreen mode Exit fullscreen mode

This division of labor becomes especially elegant when injectMutation lives inside a Custom Injection Function. The CIF handles all the query logic, while UI-facing actions stay in the component that drives the interface. This also boosts the CIF's reusability — UI interactions may differ from case to case, but the invalidation logic will almost always stay consistent.

Once more, I encourage you to check out this outstanding article by @tkdodo.

Curious About Something? Start with the FAQ

As you venture further into TanStack Query and start using it for trickier scenarios, you'll naturally accumulate questions. It's unfamiliar territory, after all. But Angular developers have a head start here, since TanStack Query has been a staple in the React world for quite a while. Most — if not all — of the questions you'll run into have already been raised and answered! Dominik has again done the community a solid by compiling responses to the most common questions in this helpful post.

Should you add yet another package?

It's true that TanStack Query requires some adjustment. Yet the effort isn't so much about learning new concepts as it is about letting go of the habit of hand-managing asynchronous state. Instead, you adopt a straightforward mental model built around hierarchical query keys, declarative data fetching inside components, and knowing exactly when to invalidate a query once you're certain the server-side data has shifted.

If you bring TanStack Query into your codebase, the likely payoffs are:

  1. You'll delete a significant amount of intricate, error-prone code and swap in a small amount of TanStack Query logic.
  2. Your codebase becomes easier to maintain, and adding new features no longer means wiring up fresh server state plumbing by hand.
  3. Your application becomes noticeably snappier and more responsive from the user's perspective.
  4. You may even cut down on network usage and see better memory performance.

So the question remains—is the trade-off worth it?

Does this mean NgRx is obsolete?

Not at all, but it will spare you from writing a lot of boilerplate for server state. Earlier I walked through all the considerations that go into building a proper, declarative server state solution. Now imagine having to implement all of that yourself inside an NgRx store.

Spare yourself the trouble. The TanStack Query team has already solved the hard parts of server state management. It works exceptionally well right out of the box with no configuration, and you can tweak it as your needs evolve.

What TanStack Query won't handle is your client-side global state. It has no mechanism for tracking complex UI interaction flows that show up in enterprise apps, nor does it know anything about maintaining an in-memory list of draggable elements whose names and descriptions you can edit before hitting save. That's where NgRx and other client state libraries in the Angular world do their best work—and they'll remain our trusted allies.

Where to go from here?

I strongly encourage you to explore the official TanStack Query documentation. It's exceptionally well written and gets you productive quickly.

If anything remains unclear after reading the docs, chances are @tkdodo has covered it on his blog with a thorough explanation and a demonstration that makes the concept click.

For a deeper Angular-specific walkthrough, I recommend this piece by Tomasz Ducin.

One thing to keep in mind: the Angular port is still in experimental territory. Breaking changes can appear in any release, so using it means accepting that risk.

That said, the core of TanStack Query is already on its fifth major version. It's proven in millions of production apps, and it's only now making its way into the Angular ecosystem.

To help shape the best possible Angular experience, you're invited to provide feedback and join the conversation on GitHub, which you can find here.

So why hold back? Give TanStack Query a shot—you won't want to go back. I've only been using it for a few weeks and I'm already completely sold.

This is your sign(al) to try TanStack Query & Angular!!!

As always, do you have lingering questions? What do you make of TanStack Query? Could you picture adding it to your stack? Would an example app showing queries and mutations in components, directives, or services help? Or is there another topic you'd like me to explore? I'm eager to hear from you—feel free to drop a comment or reach out directly.

And if you enjoyed the article, consider liking and sharing it. If you want more of my content, follow me on Twitter or GitHub.