Back to all articles
BackendNodejsReact
Tanstack Query

Tanstack Query

  • If you’ve ever built a React app, you know the pain of managing server data. You write useEffect hooks, add loading states, track errors, and somehow still end up with messy, repetitive code. What if there were a tool that handled all this for you efficiently and automatically? That’s exactly where TanStack Query comes in.

The useEffect problem

  • Before TanStack Query, developers handled data fetching with useEffect. That meant manually managing loading states, error handling, cleanup, and sometimes even cancellation. If you forgot a dependency array, you could end up with infinite loops or miss cleanup steps entirely. All of this made data fetching hard and messy — and it doesn’t even handle caching.

TanStack Query

  • TanStack Query is a powerful server-state management library for React applications. It was created by Tanner Linsley. Think of it as your personal assistant that handles all the messy details of fetching, caching, synchronizing, and updating server data.

Why choose TanStack Query?

  • Imagine having a smart friend who remembers what you asked, knows when the answer is old, and tells you right away when something changes. That’s what TanStack Query does:
  • Automatic Caching: Remembers data you have already fetched
  • Background Updates: Keeps your data fresh without extra effort
  • Optimistic Updates: Makes your app feel fast and responsive
  • Error Recovery: Retries failed requests automatically
  • Pagination and Infinite Loading: Handles complex data patterns with ease

Under the hood of TanStack Query

  • Under the hood, TanStack Query uses the Observer Pattern and React’s Context API.

Observer pattern

  • TanStack Query uses the Observer Pattern. When your data (the subject) changes, all components that depend on it (the observers) update automatically. It’s like subscribing to a newspaper: when new data arrives, every subscriber gets the latest edition right away.

Context API

  • It also uses React’s Context API. The QueryClient acts as the brain that stores cache and query states, and the QueryClientProvider makes this data available across your app. This way, all components stay in sync without manual work.

Query fundamentals

  • To use it, you first create a QueryClient, which manages the cache where all your fetched data lives. Then, you wrap your app with the QueryClientProvider, which makes that cache available anywhere in your component tree.

One thing to note about QueryClient is you need to make sure that you create it outside of your most parent React component. This makes sure your cache stays stable even as your application re-renders.

However, because the QueryClient is created and located outside of React, you’ll need a way to distribute it throughout your application so that you can interact with it from any component.

This brings us to the first React-specific API you’ll need: the QueryClientProvider.

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient();

export default function Root() {
  return (
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  );
}

useQuery hook

  • The useQuery hook is designed to handle asynchronous data fetching with minimal configuration. It automates common tasks such as caching, background refetching, and error handling, allowing you to focus on building your application.

queryKey and queryFn

useQuery requires two main things:

  1. queryKey: A unique identifier for your query.
    • Can be a string or an array.
    • Helps React Query know which cached data belongs to which query.
    • Example: ['pokemon', id] — here, id makes each Pokémon query unique.
  2. queryFn: A function that returns a promise with your data.
    • Usually a fetch or API call.
const fetchPokemon = () =>
  fetch(`https://pokeapi.co/api/v2/pokemon/${id}`).then((res) => res.json());
import { useQuery } from "@tanstack/react-query";

function PokemonList() {
  const {
    data: pokemon,
    isLoading,
    error,
  } = useQuery({
    queryKey: ["pokemon"],
    queryFn: () =>
      fetch("https://pokeapi.co/api/v2/pokemon?limit=20")
        .then((res) => res.json())
        .then((data) => data.results),
  });

  if (isLoading) return <div>Loading Pokemon...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <ul>
      {pokemon?.map((p) => (
        <li key={p.name}>{p.name}</li>
      ))}
    </ul>
  );
}

By default, it provides:

  • Automatic Caching: useQuery caches the fetched data, reducing the need for redundant network requests.
  • Background Refetching: It can automatically refetch data in the background to keep it up-to-date.
  • Error Handling: Provides built-in mechanisms to handle errors gracefully.
  • Loading States: Automatically manages loading and error states, eliminating the need for manual state management.

There are only two hard things in Computer Science: cache invalidation and naming things.

  • So how exactly does Tanstack Query handle this cache complexity?

Understanding staleTime and gcTime

  • Two important concepts in TanStack Query are staleTime and gcTime, which control data freshness and memory management, respectively.

staleTime

  • staleTime determines how long the data remains fresh before TanStack Query considers it stale.
  • For example, if we set staleTime to 5 minutes, the queryFn will not run again during that time.
  • You might wonder: what is the default staleTime?
  • Surprisingly, it is 0 ms. This means every query is considered stale immediately after fetching.

gcTime

  • gcTime specifies how long unused data stays in memory after all components stop using it. For instance:
  • Default: 5 minutes (300000 ms).
  • Key rule: Active queries are never garbage collected. A query is considered active as long as it has observers (components using that query).

When all observers unmount, the query becomes inactive, and the gcTime timer starts.

How It Works

  1. You fetch data with a query.
  2. The component using the query creates an Observer.
  3. When the component unmounts, the observer is removed.
  4. If no observers remain, the cache entry becomes inactive, and gcTime starts.
  5. Once gcTime passes, the cache entry is removed from memory.
const { data } = useQuery({
  queryKey: ["pokemon"],
  queryFn: fetchPokemon,
  staleTime: 5 * 60 * 1000, // 5 minutes
  gcTime: 10 * 60 * 1000, // 10 minutes
});
  • This does bring up one final question, though: how exactly does TanStack Query know when to refetch the data and update the cache?

React Query automatically refetches data in four main scenarios if the query is stale:

  1. queryKey changes – e.g., when a filter or ID changes.
  2. A new observer mounts – when a new component using useQuery appears.
  3. Window gains focus – when the user switches back to the tab.
  4. Device goes online – when the app reconnects after being offline.

Fetching data on demand with useQuery

By default, useQuery fetches data immediately when a component mounts. Sometimes, you want to wait before fetching, or only if an id is present — for example, when you need user input first. React Query provides multiple ways to handle this.

1) Using the enabled option

The enabled property lets you control when a query should run. It accepts a boolean:

import { useQuery } from "@tanstack/react-query";

function usePokemon(id) {
  return useQuery({
    queryKey: ["pokemon", id],
    queryFn: () =>
      fetch(`https://pokeapi.co/api/v2/pokemon/${id}`).then((res) =>
        res.json()
      ),
    enabled: !!id, // fetch only if id is present
  });
}

export default function App() {
  const [id, setId] = React.useState("");

  return (
    <div>
      <input
        type="number"
        onChange={(e) => setId(e.target.value)}
        placeholder="Enter Pokémon ID"
      />
      {id && <PokemonDetails id={id} />}
    </div>
  );
}

function PokemonDetails({ id }) {
  const { data, isLoading, status } = usePokemon(id);

  if (isLoading) return <div>Loading...</div>;
  if (status === "error") return <div>Error fetching data</div>;

  return <div>{data.name}</div>;
}

Polling data

  • Take this scenario: say you were building a crypto analytics dashboard. More than likely, you’d want to make sure the data is always up to date after a certain amount of time, regardless of whether a “trigger” occurs.

  • To achieve this, you need a way to tell React Query that it should invoke the queryFn periodically at a specific interval, no matter what.

  • This concept is called polling, and you can achieve it by passing a refetchInterval property to useQuery when you invoke it.

useQuery({
  queryKey: ["dashboard", { id }],
  queryFn: () => fetchRepos(id),
  refetchInterval: 5000, // 5 seconds
});
  • Now with a refetchInterval of 5000, the queryFn will get invoked every 5 seconds, regardless of if there's a trigger or if the query still has fresh data.
  • Because of this, refetchInterval is best suited for scenarios where you have data that changes often and you always want the cache to be as up to date as possible.

Parallel queries with React Query

In real-world applications, you often need to fetch multiple resources simultaneously. React Query makes this easy with useQuery and useQueries.

Fetching resources in parallel improves performance. For example, suppose you’re building a Pokémon Dashboard:

  • You want to show a list of Pokémon.
  • You also want to show a list of Pokémon abilities.

These two resources are independent, so there’s no reason to wait for one to finish before fetching the other.

Basic Parallel Queries with useQuery

You can call multiple useQuery hooks in the same component:

If you want a single hook to handle multiple queries while keeping them separate in cache, use useQueries:

import * as React from "react";
import { useQuery } from "@tanstack/react-query";

function fetchPokemonList() {
  return fetch("https://pokeapi.co/api/v2/pokemon?limit=10").then((res) =>
    res.json()
  );
}

function fetchAbilitiesList() {
  return fetch("https://pokeapi.co/api/v2/ability?limit=10").then((res) =>
    res.json()
  );
}

function usePokemon() {
  return useQuery({ queryKey: ["pokemon"], queryFn: fetchPokemonList });
}

function useAbilities() {
  return useQuery({ queryKey: ["abilities"], queryFn: fetchAbilitiesList });
}

export default function App() {
  const pokemon = usePokemon();
  const abilities = useAbilities();

  return (
    <div>
      <h1>Pokémon Dashboard</h1>

      <h2>Pokémon</h2>
      {pokemon.isPending && <p>Loading Pokémon...</p>}
      {pokemon.isError && <p>Error: {pokemon.error.message}</p>}
      {pokemon.isSuccess && (
        <ul>
          {pokemon.data.results.map((p) => (
            <li key={p.name}>{p.name}</li>
          ))}
        </ul>
      )}

      <h2>Abilities</h2>
      {abilities.isPending && <p>Loading Abilities...</p>}
      {abilities.isError && <p>Error: {abilities.error.message}</p>}
      {abilities.isSuccess && (
        <ul>
          {abilities.data.results.map((a) => (
            <li key={a.name}>{a.name}</li>
          ))}
        </ul>
      )}
    </div>
  );
}
  • Each Pokémon’s details are fetched in parallel.
  • The queries are cached separately, so you can reuse them elsewhere.

Deriving Combined Data

You can compute combined values across queries:

const pokemonDetails = usePokemonDetails(pokemon.data.results);
const totalAbilities = pokemonDetails
  .map((q) => q.data?.abilities.length ?? 0)
  .reduce((a, b) => a + b, 0);
  • This approach allows creating dashboards or summaries from parallel queries without coupling them.

Prefetching and placeholder data

  • Prefetching fetches data before the user needs it to eliminate perceived latency.

  • Use the right tool for the job:

    1. prefetchQuery: warm the cache ahead of navigation or interaction
    2. initialData: seed the cache with known data (for example, from a list item or SSR)
    3. placeholderData: show a temporary shape while the real data loads
  • Here is a full example of prefetching using placeholder data:

export async function fetchPokemonList() {
  const res = await fetch("https://pokeapi.co/api/v2/pokemon?limit=10");
  const data = await res.json();
  return data.results; // [{ name, url }]
}

export async function fetchPokemon(name) {
  const res = await fetch(`https://pokeapi.co/api/v2/pokemon/${name}`);
  return res.json(); // full Pokémon details
}

placeholderData

is visual-only and does not mark the query as fresh.

initialData

seeds the cache and the query starts as fresh (use initialDataUpdatedAt to control staleness).

prefetchQuery

is ideal on hover, viewport entry, or route transitions.

Have feedback?

  • Was anything confusing, hard to follow, or out of date? Let me know what you think of the article and I'll make sure to update it with your advice.
Like our article?
Sinister Spd