Intro

TanStack Query (TQ) is an async data-fetching library that handles fetching, caching, synchronization, and updates server-state. It used to be called React-Query but was absorbed by the TanStack org.

TQ and Redux?

Yes, these may seem like both handle frontend state, because they do. However, they’re often used in tandem, because they serve different purposes.

  • TQ is for fetching and caching server-data.
  • Redux stores the client-side state such as UI settings, mode selections, etc; and makes them available to the entire app.

If both are used for the same data (copying fetched API results into Redux), this means you have two sources of truth, this can (and likely will) lead to bugs or state mismatch!

Why?

Most frameworks don’t have opinionated ways of state-management and data-fetching and certainly don’t handle async server-state well.

Server-state is tricky because:

  • It’s in a location you may not control or own.
  • Requires async APIs for fetching and updating.
  • Implies shared ownership and can be changed by other people without your knowledge.
  • Can end up being outdated in the app if not regularly monitored and updated.

// Incoming Massive shoulder patting…

“TanStack Query is hands down one of the best libraries for managing server state. It works amazingly well out-of-the-box, with zero-config, and can be customized to your liking as your application grows.”

Installation

They have a whole lot of options on their installation page, using package managers or CDNs. I will use NPM:

npm i @tanstack/react-query

They also recommend a plugin to catch bugs and inconsistencies but not sure I want to, frankly…

npm i -D @tanstack/eslint-plugin-query

Basic Example

They demo the core pillars of TQ with the following example.

import {
  QueryClient,
  QueryClientProvider,
  useQuery,
} from '@tanstack/react-query'
 
const queryClient = new QueryClient()
 
export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>
  )
}
 
function Example() {
  const { isPending, error, data } = useQuery({
    queryKey: ['repoData'],
    queryFn: () =>
      fetch('https://api.github.com/repos/TanStack/query').then((res) =>
        res.json(),
      ),
  })
 
  if (isPending) return 'Loading...'
 
  if (error) return 'An error has occurred: ' + error.message
 
  return (
    <div>
      <h1>{data.name}</h1>
      <p>{data.description}</p>
      <strong>👀 {data.subscribers_count}</strong>{' '}
      <strong>✨ {data.stargazers_count}</strong>{' '}
      <strong>🍴 {data.forks_count}</strong>
    </div>
  )
}

Breakdown:

  1. They import the TQ client and its hook useQuery
  2. Initialize an instance of the client, before the component for obvious reasons… // It's to avoid initializing on each component call.
  3. Wrap your components (entire app in this example) in the <QueryClientProvider> component, likely to provider access similar to Context and Redux Provider.
  4. Using the useQuery hook, we get 3 objects.
  5. The hook expects
    1. A query key (what we’re looking for)
    2. The function, fetch is fine
  6. Check if it’s returned an error or is still pending
  7. Return component’s JSX.

Core Concepts - Quick Guide

Besides the previous “basic” example, they also have a “Quick Start” example that covers the three core concepts, Queries, Mutations, and Query Invalidation.

Queries

These are declarative dependencies for async data sources, associated with a unique identifier/key.

A Query is used with Promise methods (incl. GET and POST methods) to get data from a remote source. ==// Use Mutations if you plan on mutating server-data!==

Subscribing

Components sub to queries using the previously mentioned useQuery hook. Which needs:

  • A unique key for the query
  • A function that returns a promise that:
    • Resolves the data, or
    • Throws an error Example:
import { useQuery } from '@tanstack/react-query'
 
function App() {
  const info = useQuery({ queryKey: ['todos'], queryFn: fetchTodoList })
}

The unique key you provide is used internally for refetching, caching, and sharing your queries throughout your application.

Returned values: The hook will return all the info, which is an object with several fields such as { isLoading, isError, data, error, refetch, isFetching }.

Pausing/Disabling auto fetching

Note, that useQuery runs as soon as a component begins its lifecycle (renders), if you want to hold it off, then pass the enabled:false option to it. Example:

import { useQuery } from '@tanstack/react-query'
 
function App() {
  const { isLoading, isError, data, error, refetch, isFetching } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodoList,
    enabled: false,
  })
}

Query Result States

The results from the hook have a few possible states to account for. We’ve seen these in the Basic Example breakdown.

  1. isPending or status === 'pending' : The query has no data yet, wait.
  2. isError or status === 'error': An error occurred. the error property will have more info
  3. isSuccess or status === 'success': Successfully fetched and data available. the data will be in the data property. When fetching, the query will have isFetching set to true.

Realistically, for most purposes, you’ll only need to check when it’s pending or if error’s occurred, otherwise you’ve got your data.

Fetch States

It’s got more statuses… // Not sure why they have string statuses AND boolean ones.

  1. fetchStatus === 'fetching' : The query is currently fetching.
  2. fetchStatus === 'paused': The query wanted to fetch, but it is paused. More info in the Network Mode guide.
  3. fetchStatus === 'idle': The query is not doing anything at the moment.

Two Statuses Explanation

Apparently, because it’s possible for all combinations of status and fetchStatus to occur because of background re-fetching and stale status while revalidating. Examples:

  • A successful, status == "success", query can be fetchStatus == "idle" but can also "fetching" during a refetch…
  • Queries with no data will often be status == "pending" and fetchStatus == "fetching" but can also be "paused"

State Rules of Thumb

  • The status provide information about the data, did it get it or not?
  • The fetchStatus give information about the queryFn passed to the hook. Is it running or not?

Query Invalidation

Sometimes you need to invalidate a query or render it invalid. This can be because it’s become stale, taken too long that it’s outta date, or because the user’s done an action that renders it invalid…etc. This is known as QueryInvalidation and is important when dealing with async data fetching.

the QueryClient has an invalidateQueries method that lets you, smartly, mark queries as stale and potentially refetch them too! Example:

// Invalidate every query in the cache
queryClient.invalidateQueries()
// Invalidate every query with a key that starts with `todos`
queryClient.invalidateQueries({ queryKey: ['todos'] })

When you invalidate a query, two things happend,

  • It’s status is set to stale, overriding any staleTime configs used by hooks.
  • If the query is currently being rendered by useQuery and other hooks, it’s refetched in the backgroud. // nice!

Query Matching

With methods like invalidateQueries and removeQueries you can match multiple queries by their prefix, or specifically target a query. Check out Query Filters.

Prefix Targeting

In this example, they use a prefix to target all queries that start their query key with it.

import { useQuery, useQueryClient } from '@tanstack/react-query'
 
// Get QueryClient from the context
const queryClient = useQueryClient()
 
queryClient.invalidateQueries({ queryKey: ['todos'] })
 
// Both queries below will be invalidated
const todoListQuery = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodoList,
})
const todoListQuery = useQuery({
  queryKey: ['todos', { page: 1 }],
  queryFn: fetchTodoList,
})

Variable Targeting

Can target queries with specific variables using a more specified key.

queryClient.invalidateQueries({
  queryKey: ['todos', { type: 'done' }],
})
 
// The query below will be invalidated
const todoListQuery = useQuery({
  queryKey: ['todos', { type: 'done' }],
  queryFn: fetchTodoList,
})
 
// However, the following query below will NOT be invalidated
const todoListQuery = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodoList,
})

Exclusive Targeting

If you want to invalidate only what you target, use the exact: true option in invalidateQueries along with the key.

Custom targeting

If you need more specificity, pass a predicate function in the predicate: (q)=>{...} option. It will receive each and every query, and must evaluate it to true or false for it to work. Example:

queryClient.invalidateQueries({
  predicate: (query) =>
    query.queryKey[0] === 'todos' && query.queryKey[1]?.version >= 10,
})
 
// The query below will be invalidated
const todoListQuery = useQuery({
  queryKey: ['todos', { version: 20 }],
  queryFn: fetchTodoList,
})
 
// The query below will be invalidated
const todoListQuery = useQuery({
  queryKey: ['todos', { version: 10 }],
  queryFn: fetchTodoList,
})
 
// However, the following query below will NOT be invalidated
const todoListQuery = useQuery({
  queryKey: ['todos', { version: 5 }],
  queryFn: fetchTodoList,
})

Mutations

Mutations are for CRUD effects on server-side data. TQ has a hook for this, useMutation. Example:

function App() {
  const mutation = useMutation({
    mutationFn: (newTodo) => {
      return axios.post('/todos', newTodo)
    },
  })
 
  return (
    <div>
      {mutation.isPending ? (
        'Adding todo...'
      ) : (
        <>
          {mutation.isError ? (
            <div>An error occurred: {mutation.error.message}</div>
          ) : null}
 
          {mutation.isSuccess ? <div>Todo added!</div> : null}
 
          <button
            onClick={() => {
              mutation.mutate({ id: new Date(), title: 'Do Laundry' })
            }}
          >
            Create Todo
          </button>
        </>
      )}
    </div>
  )
}

Breakdown:

Mutation States

  • isIdle or status === 'idle' :The mutation is currently idle or in a fresh/reset state
  • isPending or status === 'pending':The mutation is currently running
  • isError or status === 'error': The mutation encountered an error
  • isSuccess or status === 'success': The mutation was successful and mutation data is available There’s even more info based on the state:
  • Error: if it errors out, the error is available via the error property.
  • Success: if it succeeds, the data is available via the data property.

Quick Start Example

This example illustrates all 3 concepts in action.

import {
  useQuery,
  useMutation,
  useQueryClient,
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'
import { getTodos, postTodo } from '../my-api'
 
// Create a client
const queryClient = new QueryClient()
 
function App() {
  return (
    // Provide the client to your App
    <QueryClientProvider client={queryClient}>
      <Todos />
    </QueryClientProvider>
  )
}
 
function Todos() {
  // Access the client
  const queryClient = useQueryClient()
 
  // Queries
  const query = useQuery({ queryKey: ['todos'], queryFn: getTodos })
 
  // Mutations
  const mutation = useMutation({
    mutationFn: postTodo,
    onSuccess: () => {
      // Invalidate and refetch
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    },
  })
 
  return (
    <div>
      <ul>
        {query.data?.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
 
      <button
        onClick={() => {
          mutation.mutate({
            id: Date.now(),
            title: 'Do Laundry',
          })
        }}
      >
        Add Todo
      </button>
    </div>
  )
}
 
render(<App />, document.getElementById('root'))

References