DEV Community

Cover image for This is your sign(al) to try TanStack Query & Angular
Robin Goetz for This is Angular

Posted on • Updated on

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

In version 16, Angular released signals. A reactive primitive promised by the core team that would help take the framework to the next level. A primitive that allows for fine-grained rendering updates and a world where Angular is not reliant on ngZone for change detection anymore. A primitive that also provides the open source ecosystem with a new way to react to changes in values in components, directives, and services.

Now about 7 months later, the Angular community has absolutely embraced the signals sent by the core team and is building incredible new tools and libraries on top of this new technology.

Projects like ngrx/signals, ngxtension with signalSlice or combine, come to mind immediately.

But not only do signals enable new innovation inside the existing ecosystem, they also enable developers to port incredibly powerful solutions to Angular. By leveraging signals and their new way of notifying and reacting to changes in values that is significantly less complex than RxJs.

One of these projects, and the one that I am (currently) most excited about is arnoud-dv's official adapter that brings TanStack Query to the Angular ecosystem.

In this article, I'll introduce TanStack Query and try make this your sign(al) to give TanStack Query & Angular a try!

What is TanStack Query?

TanStack Query is a data-fetching library that originated in the React ecosystem. It is an opinionated tool that allows you to easily fetch, cache, synchronize and update server state in web applications.

If you have worked on bigger Angular applications, you probably realized that while the framework provides its own HttpClient to fetch remote data, it does not come with an opinionated way of managing the state of those interactions. We might create our own solutions with global NgRx or NGXS stores that allow us to access server data, let us manage entity collections, and manually trigger requests to fetch new information whenever it's necessary. Maybe we even develop some sort of opinionated meta-server-state-management tool to bring some order to the chaos.

However, while NgRx, NGXS, or Elf are powerful state management libraries, they were often created for working with client state and not explicitly designed around managing asynchronous or server state.

What is server state?

Let's look at how the TanStack Query team defines it:

For starters, server state:

  • Is persisted remotely in a location you do not control or own
  • Requires asynchronous APIs for fetching and updating
  • Implies shared ownership and can be changed by other people without your knowledge
  • Can potentially become "out of date" in your applications if you're not careful

This (unsurprisingly) makes perfect sense. Our data often lives in a remote database, not in the browser. To access and alter this data we need to communicate with our servers through API calls. These calls are always asynchronous. Others often have access to the same data. This means that although we might have just fetched a local copy of the data from the API, someone else could have already changed the underlying record in the database.

Bummer... Looks like managing server state is actually a lot more complex than calling this.http.post.

As we grasp this nature of server state in our applications, the TanStack team points out that even more challenges will inevitably arise as we go:

  • Caching... (possibly the hardest thing to do in programming)
  • Deduping multiple requests for the same data into a single request
  • Updating "out of date" data in the background
  • Knowing when data is "out of date"
  • Reflecting updates to data as quickly as possible
  • Performance optimizations like pagination and lazy loading data
  • Managing memory and garbage collection of server state
  • Memoizing query results with structural sharing

Oof...

Just reading these points makes all my alarm signals go off and scream: Complexity!! Don't get me wrong, every once in a while I like a technical challenge (like building spartan/ui), but this just seems like an absolute nightmare to deal with...

Especially, if there already exists a battle tested server state-management solution that let's you "[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 love declarative solutions to complex problems. Even more those that people that are a lot smarter than me built with all the different edge cases in mind. Those solutions that let us use the fruits of years of their hard work so we can focus on building OUR applications.

I love TanStack Query!

It's all about the Queries!

As the name already implies TanStack Query is built around the concept of Queries. The library takes care of all the complexities of fetching the data for these queries, manages updating the data, and can notify you when the data becomes out of date, and so much more.

Let's start with taking a look at an example of a simple query that fetches data 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

This code is all that is needed to fetch the data, cache it effectively, and have all the challenges of managing server state we explored above to be taken care of for us.

We only give TanStack Query, using the injectQuery function, a unique identifier, the queryKey, for the data we are interested in and then tell it how to fetch the data with a queryFn.

With this in mind, let's dig a little deeper and try to understand what makes TanStack Query tick (no change detection pun intended.)

So what is a Query again?

From the docs:
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 explains the two options we see getting passed to injectQuery in our example:

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

The ['repoData'] array is used as a unique query key. This key is associated with a "container" that holds all the data associated with the state & data of our query. If you are wondering why this key is an array and not a simple string, worry not. We will go into more detail on this in a second.

To update our query's data we need a promised based method to fetch data from the server, the queryFn. In Angular interactions with the server happen through the HttpClient. Because the HttpClient returns an Observable we are using lastValueFrom to make our server client interaction promise based:

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

The query result, which is returned by the injectQuery function, contains all of the information about the query that you'll need for templating and any other usage of the data as we can see 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 contains a few very important states you'll need to be aware of to be productive. A query can only be in one of the following states at any given moment:

  1. isPending or status === 'pending' - The query has no data yet
  2. isError or status === 'error' - The query encountered an error
  3. isSuccess or status === 'success' - The query was successful and data is available

Beyond those primary states, more information is available depending on the state of the query:

  1. error - If the query is in an isError state, the error is available via the error property.
  2. data - If the query is in an isSuccess state, the data is available via the data property.
  3. isFetching - In any state, if the query is fetching at any time (including background refetching) isFetching will be true.

Why is our queryKey an array?

So far so good, we know that injectQuery gives us an optimized way to store server-state for a query key and an associated promised based server-client interaction, but why do we use an array as a key? Wouldn't a string make more sense?

When in doubt, take a look at TanStack Query's incredible docs. They state the following:

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!

While there might be some hints on why you'd want unique and serializable keys, this alone does not yet tell me why they decided to go with an Array instead of using strings. Let's look at a couple examples to understand this decision.

For the simplest of use cases such as generic lists or non-hierarchical resources a simple string would probably work.

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

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

However, keeping them as an array will be well worth it! In reality our queries often need more information to uniquely describe their data. That is the reason we are using an array for our query keys. We can now combine strings and any number of serializable objects to describe 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 you see, when we deal with hierarchical or nested resources, and pass an ID, index, or other primitive to uniquely identify the item, or deal with
queries that rely on additional search parameters, the array approach and its intrinsic hierarchy shines.

Important to remember: Because query keys uniquely describe the data they are fetching, they always should include any variables you use in your query function that change.

Why do we need a function to return our options instead of passing them in directly?

The tl;dr is that we can think of the function we are using to pass in our options as an effect for Angular's signals. That means the options are now updated whenever one of the signals used to construct them changes. Our query starts to react to the signals we use to declare it.

Let's take a closer look how we ended up with this signal powered approach.

Origins of TanStack Query in React

TanStack Query originated as a React data fetching library.

In React world everything is a component. There is no concept of services or directives. Your application is ultimately composed of only components. React re-renders a component whenever one of it's props, which are values passed directly to the component, or some local state, declared with useState, change.

React does not have a dependency injection mechanism in the same way Angular does. Instead it relies on a concept called hooks, e.g. useQuery. These hooks are functions that are re-executed whenever the component they are called from is re-rendered.

TanStack Query manages all of it's server state outside of 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 also does the same in Angular. To make our example from earlier work, we actually need to create the QueryClient as a global object and provide it when bootstrapping 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 code to setup TanStack Query with React and Angular actually look pretty similar. Both times TanStack's QueryClient is instantiated outside the actual application. The core of it is framework agnostic.

However, to take advantage of all the benefits TanStack provides for server-state management we need to connect this queryClient with our application.

In React we do this with the useQuery hook to which we pass our query keys. Hooks are re-rendered when components input props or state changes. If a query key depends on props or state, the query key changes, which in return propagates the change 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

Reactive Angular

In Angular on the other hand, not everything is a component and there is no concept of a component tree re-rendering. Instead Angular comes with it's own Change Detection Mechanism based on ngZone. For TanStack Query this means that the simple paradigm of input change or (non reactive) state change triggers function call with new param, which changes query key which causes TanStack Query to do its magic, does not work anymore.

However, this reaction to an input or state change does sound suspiciously familiar to what effect's do with signals. And while the specifics are more complicated this is actually a good first intuition of how signals allow such an intuitive 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 containing state in Angular, changes. The TanStack Query client is made aware of this page change. The query key that is composed of the page value is updated, and the associated data is refetched accordingly.

As @arnouddv pointed out, there is another reason to return the options from 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: Update the server, invalidate the client

Now that we have a better understanding of how we can leverage queries to fetch and manage async server state, we take a closer look and updating server state, and how we can ensure that these updates are also reflected in our client side data.

To create/update/delete data or perform server side-effects, TanStack Query introduces the concept of mutations and exports a injectMutation function to carry them out.

For example if you wanted to create a new todo on the server you could use 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 mutation object contains the same state indicators as a query. isIdle, isPending, isError, isSuccess, error, and data.

It also has a mutate function, which triggers the mutationFn that we pass into the injectMutation's options object. In the example above, a click on the Create Todo button starts executing our mutation. You can also see that variables can be passed to our mutations function by calling the mutate function with a single variable or object.

It is sometimes the case that you need to clear the error or data of a mutation request. To do this, each mutation has a reset function, which can be called to handle this.

Overall, mutations are actually a lot more straightforward than queries. However, it is often the case that after mutating some state on the server, we need to make sure that our queries reflect those changes on the frontend. To do that, we can pass an onSuccess function to the injectMutation options object. This function is called after the mutation successfully completed, which means it is the perfect place to ensure that your frontend data is up to date.

There are two ways we can make this happen:

  1. We invalidate the query keys that we know are affected by our mutation and let TanStack Query handle the refetching of the data. This shows the true power of declarative server-state management that is completely driven by query keys.
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

Internally, TanStack Query uses fuzzy matching on the query key. That means that if you have multiple keys for your comments list, they will all be invalidated. Only currently active keys will be refetched, others are marked as stale and refetched the next time they are used.

Let's say we have the option to sort our comments. When the new comment was added, two queries with comments are in our cache:

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

We're only displaying one of them on the screen. invalidateQueries refetches that one and marks the other one as stale. The user will always see the latest data.

  1. We know exactly what data on the frontend needs to be updated as a result of our mutation and set the new data directly inside our query client. This should be used with caution, but does avoid another (background) API call to our server.
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

Putting data into the cache directly via setQueryData will act as if this data was returned from the backend. All components using that query will update accordingly.

Invalidation should be preferred and while it depends on the use-case, for direct updates to work reliably, you need more code on the frontend, and to some extent duplicate logic from the backend. For example, sorted lists are pretty hard to update directly. The position of the entry could've potentially changed due to the update. Invalidating the whole list is most often the "safer" approach.

Is TanStack Query for components only or do directives and services work too?

Since directives are just components without templates, the same code, for queries and mutations, that we used in our examples above would also work in any directive.

Even more important to understand is that TanStack Query actually does not care where it is used. As we saw above, TanStack Query manages our server-state outside of our Angular application. The only limitation on where to use Angular Query is that the injectQuery function needs to be called from an injection context.

This is actually an Angular dependency injection limitation. However, since the instantiation of a class is run in an injection context it feels very natural to declare the query variable as a class variable. However, if you want to learn more about Angular's injection context I recommend this article.

Finally, the injectQuery function provides all it's internal state and data as signals. We can use those signals to integrate with other code of any of our components, including the template, directives or services.

How I use TanStack Query with modern Angular

Write Reusable Queries with Custom Injection Functions

When I started using TanStack Query for my Angular applications, I quickly realized that I do not always want to have to rewrite the same queryKey and queryFn everywhere. This seems like a great way to introduce inconsistencies and bugs that are hard to debug. What I wanted is a way to reuse queries.

This is where we can leverage the power of custom injection functions (CIF) and inject reusable queries. To these CIFs I like to pass in a params signal that drives the query, and an optional injector, which allows users to leverage the CIF is places where no injection context is available. I highly recommend reading this article by Chau, if you want to learn more about the reasoning behind this.

Putting these ideas together, the source code for a reusable query looks 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

Where the assertInjector function from ngxtension looks 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

It can be consumed in any component, directive, or service. With the optional Injector you can now delay injecting the query until you need it, e.g. after Inputs become available in ngOnInit.

Read the docs & TkDodo's blog

Besides these "Angular specific" challenges, I try to study and emulate all the pattern's that the TanStack documentation lays out or find myself reading the incredible blog posts @tkdodo has published on his personal blog! So far they have an answer to any question I could ever have about TanStack Query.

Here are some insights I consider absolutely essential from his blog:

Always keep good Query Keys hygiene

Keep your Query Keys next to their respective queries, co-located in a feature directory or Nx library:

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

Structure your Query Keys from most generic to most specific, with as many levels of granularity as you see fit in between. Here's an example structure for a todos list that allows for filterable lists as well as detail views:

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

So far we have been manually declaring the Query Keys a lot. This error-prone and makes changes harder in the future if you wanted to add another level of granularity to your keys.

@tkdodo recommends to use Query Key factory per feature: A simple object with entries and functions that will create query keys. For the above structure, it would 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

But don't take my word for this and read Dominik's article that goes into much more detail.

Keep this in mind to master mutations

injectQuery is declarative, injectMutation is imperative.

TanStack's Queries mostly run automatically. We define the dependencies, but TanStack Query takes care of running the query immediately. It also performs smart background updates when necessary. This works great for queries because we want to keep what we see on the screen in sync with the actual data on the backend.

For mutations, this doesn't make sense: Imagine a new todo is created every time the user focuses their browser window... Thus, instead of running a mutation instantly, TanStack Query gives us a function that can be invoked whenever we want to make the mutation.

For this reason, mutations also do not share state like injectQuery does. You can use injectQuery multiple times in different components and will get the same, cached results. This is not the same for injectMutation. Each time you will get a new mutation, which keeps track of its own state and can be triggered with it's mutate-method.

Be aware of await-ed Promises

Promises returned from the mutation callbacks are awaited by TanStack Query. invalidateQueries returns a Promise. If you want your mutation to stay in loading state while your related queries update, you have to return the result of invalidateQueries from the callback!

Know when to use mutate or injectMutation callbacks

You can have callbacks on injectMutation as well as on mutate itself. Know that the callbacks on injectMutation fire before the callbacks on mutate. Callbacks on mutate might not fire at all if the component/directive/service is destroyed before the mutation has finished.

It's good practice to separate concerns in your callbacks:

  • Do things that are absolutely necessary and logic related (like query invalidation) in the injectMutation callbacks.
  • Do UI related things like redirects or showing toast notifications in mutate callbacks. If the user navigates away from the current screen before the mutation finishes, those will purposefully not fire.
// 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 separation is especially neat if injectMutation is part of a Custom Injection Function, CIF. The CIF contains all query related logic while UI related actions come from the component driving the UI. This also makes the CIF more reusable, because how you interact with the UI might vary on a case by case basis - but the invalidation logic will likely always be the same!

Again, do yourself a favor and read this this incredible article by @tkdodo.

Got a question? Read the FAQs!

As you dive deeper into TanStack Query and are starting to use it for more advanced use cases you will inevitably end up with a bunch of questions. It's new technology after all. However, us Angular developers have a huge advantage, because TanStack Query has been used in the React community for years most, if not all, of your questions have already been asked, and answered! Dominik again, did us a huge favor and consolidated answers to the most frequent questions in this great article.

Why introduce another dependency?

Yes, TanStack Query does come with a learning curve. However, I would describe this learning curve more as a period of unlearning async state micro-management and replacing it with a straightforward concept of hierarchical query keys, declarative data fetching in components, and knowing when to invalidate queries of which we know the server data has changed.

If you decide to add TanStack Query to your application, it will very likely:

  1. Help you remove many lines of complicated and misunderstood code from your application and replace with just a handful of lines of TanStack Query logic.
  2. Make your application more maintainable and easier to build new features without worrying about wiring up new server state data sources
  3. Have a direct impact on your end-users by making your application feel faster and more responsive than ever before.
  4. Potentially help you save on bandwidth and increase memory performance.

So it's up to you. Is it worth adding another dependency?

So this replaces NgRx right?

No, but it will greatly reduce code you have to write yourself to manage server state. I went into great detail in the beginning describing all the requirements one has to consider when developing a solution that adequately and declaratively manages server state. Imagine yourself (re-)implementing an NgRx store that takes care of everything above.

Don't do that to yourself. The creators of TanStack Query already built an amazing solution that deals with complexities of managing server state for you. It works amazingly well out-of-the-box, with zero-config, and can be customized to your liking as your application grows.

What TanStack Query does not do for you is manage your application's global client state. It is simply not built to keep track of a complex chain of UI interactions, which we so often deal with in enterprise applications, or knows how to keep track of changes to an in memory list of elements that we are reordering with drag and drop, whose names and descriptions we can edit, all before hitting save. That's where NgRx and all the other client state libraries of the Angular ecosystem shine and will continue to be our trusted companions.

What's next?

I highly recommend all of you to check out the TanStack Query documentation. It is incredibly good and gives you everything to get up and running in no time.

Even better, if there is any question left unanswered by the official documentation you can bet that @tkdodo has written post on his blog about the topic that explains and demonstrates the concept in a way that is absolutely incredible!

If you want to get a more thorough Angular specific introduction, this article by Tomasz Ducin is well worth the read!

Keep in mind that the Angular port is currently in an experimental stage. Breaking changes can still happen in any release and we are using this incredible piece of code at our own risk.

However, the core of TanStack Query is in it's 5th major version already. It's battle tested in millions of production apps and finally making its way to the Angular ecosystem.

To ensure that we get the best TanStack Query experience possible we are also encouraged to share feedback and participate in the discussion on GitHub, which you can check out here!

So what are you waiting for? Give TanStack Query a try and never look back! I have been using it for a few weeks now and am already absolutely hooked!

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

As always, do you have any further questions? What do you think of TanStack Query? Could you see yourself adding it to your project? Do you think you'd need to see an example app where queries and mutations are used in components, directives, or services? Do you have any other topics you'd like me to write about? I am curious to hear your thoughts. Please don't hesitate to leave a comment or send me a message.

Finally, if you liked this article feel free to like and share it with others. If you enjoy my content follow me on Twitter or GitHub.

Top comments (3)

Collapse
 
arnouddv profile image
Arnoud

Great article Robin! To elaborate on why options is returned from a function, this also 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.

Collapse
 
goetzrobin profile image
Robin Goetz

Thanks so much! Is it cool if I add this as a paragraph in the article?

Collapse
 
arnouddv profile image
Arnoud

Yes of course!