Skip to content
C Codeloom
React

React TanStack Query Tutorial

Master TanStack Query for React data fetching with caching, background refetching, mutations, and invalidation — the modern replacement for ad-hoc useEffect fetching.

·4 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • Why server state is different
  • Queries, mutations, and keys
  • Stale time vs cache time
  • Invalidation patterns
  • Common mistakes that break caching

Prerequisites

  • Familiar with React hooks and async/await

TanStack Query, formerly React Query, is the de facto library for managing server state in React. It handles caching, retries, background refetches, and invalidation so you stop writing the same useEffect fetch over and over. This tutorial covers the model and the patterns.

What and Why

There are two kinds of state in a React app. Client state is local UI: toggles, form drafts, modals. Server state is data owned by a backend that you cache locally: users, orders, posts. They look similar but behave differently. Server state is stale by default, shared across components, and lives somewhere you do not control.

TanStack Query treats server state as a cache, not as state you own. You declare what you want, it manages when to fetch, refetch, retry, and garbage-collect. The mental shift from useEffect(fetch) to useQuery is the biggest productivity gain in modern React.

Mental Model

Every piece of server data has a key. The key is your cache identifier. When two components ask for the same key, they share one fetch and one result. When the data is stale, the next access triggers a background refetch while showing the cached value, so users never see a blank screen.

Mutations are how you change data. After a mutation succeeds, you invalidate the keys that depend on it. Invalidation marks them stale, and the next access refetches.

Hands-on Example

Here is a list and a mutation that adds an item.

useQuery(['todos'])
 |
 v
[cache hit?] -- yes --> show cached, refetch in background
 | no
 v
fetch -> cache -> render

mutation success
 |
 v
queryClient.invalidateQueries(['todos'])
 |
 v
next access -> refetch -> update UI
Query lifecycle with invalidation
import { QueryClient, QueryClientProvider, useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Todos />
    </QueryClientProvider>
  );
}

function Todos() {
  const qc = useQueryClient();
  const { data, isLoading, error } = useQuery({
    queryKey: ['todos'],
    queryFn: () => fetch('/api/todos').then((r) => r.json()),
    staleTime: 30_000,
  });

  const addTodo = useMutation({
    mutationFn: (title) =>
      fetch('/api/todos', { method: 'POST', body: JSON.stringify({ title }) }),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['todos'] }),
  });

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error</p>;
  return (
    <>
      <button onClick={() => addTodo.mutate('New task')}>Add</button>
      <ul>{data.map((t) => <li key={t.id}>{t.title}</li>)}</ul>
    </>
  );
}

staleTime: 30_000 says the data is fresh for thirty seconds. During that window, navigating between components does not trigger a refetch. After expiry, the next mount refetches in the background.

Common Pitfalls

Setting staleTime to zero everywhere is the most common waste. Every focus triggers a refetch, every mount triggers a refetch, and you lose the cache benefit. Pick a sensible default in QueryClient defaults, then override per query.

Unstable query keys destroy the cache. A key like ['user', { id, includeOrders: true }] works because objects are serialized deterministically. A key that includes a new array literal each render breaks identity.

Forgetting to invalidate after mutations is the second most common bug. The UI looks fine because the mutation succeeded, but the cache is stale and the next page navigation shows old data.

Putting client-only state in TanStack Query is a category error. A modal open flag belongs in useState, not a query.

Best Practices

Centralize query keys in a constants file or a factory so renames stay consistent. Many teams use a keys.ts with todos.all, todos.detail(id), and friends.

Use select to narrow data without extra queries. If a component only needs a count, select the count, not the array. Re-renders shrink without changing the cache.

For mutations that update UI immediately, use onMutate for optimistic updates and roll back in onError. The library has first-class support for this.

Pair with the devtools. Watching the cache live makes the model click in minutes.

Wrap-up

TanStack Query is one of those libraries that changes how you think about data. Once server state is a cache, the rest of your code gets simpler. Start with a single query, set sensible defaults, and let the library handle the boring parts.