Back to CourseAdvance5 min read
React /

TanStack Query for Server State

Cache, deduplicate, refetch and invalidate server data with the library that removed most data fetching boilerplate.

Bishal Biswas

Bishal Biswas

Self Learner

Published on: May 20, 2026
Last updated: September 5, 2026
Advance Topic

Server state is different

Client state is yours. You set it, you own it, it is always correct.

Server state is a copy of something you do not control. It can be out of date the moment it arrives. Someone else may have changed it. It needs caching, refetching and invalidating.

Treating them the same is why hand rolled fetching gets so complicated. TanStack Query, formerly React Query, exists specifically for the second kind.

Setup

npm install @tanstack/react-query
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000,
      retry: 2
    }
  }
});
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Shop />
    </QueryClientProvider>
  );
}

Queries

import { useQuery } from '@tanstack/react-query';
function ProductList() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['products'],
    queryFn: async () => {
      const response = await fetch('/api/products');
      if (!response.ok) throw new Error('Failed to load products');
      return response.json();
    }
  });
  if (isLoading) return <Spinner />;
  if (error) return <p>{error.message}</p>;
  return <ul>{data.map((p) => <li key={p.id}>{p.name}</li>)}</ul>;
}

Compare that with the manual version from the fetching topic. The same lines, but you also get caching, deduplication, retries and background refetching.

Note queryFn must throw on failure. Returning a failed response means the query looks successful.

Query keys

The key identifies the cached data. Everything hangs off it.

useQuery({ queryKey: ['products'] });
useQuery({ queryKey: ['products', productId] });
useQuery({ queryKey: ['products', { category, page }] });

Anything in the key that changes triggers a refetch, so every value the query depends on must be in it.

useQuery({
  queryKey: ['products'],
  queryFn: () => fetchProducts(category)
});
useQuery({
  queryKey: ['products', category],
  queryFn: () => fetchProducts(category)
});

The first serves stale data when the category changes. This is the most common mistake with the library.

What you get for free

Deduplication. Three components asking for ['products'] produce one request.

Caching. Navigate away and back and the data is instantly there, then refreshed in the background.

Refetch on focus. Return to the tab and the data quietly updates.

Retries with exponential backoff.

Garbage collection of unused cache entries.

staleTime and gcTime

The two settings worth understanding.

useQuery({
  queryKey: ['products'],
  queryFn: fetchProducts,
  staleTime: 5 * 60 * 1000,
  gcTime: 10 * 60 * 1000
});

staleTime is how long data is considered fresh. While fresh, it is served from cache with no request at all. The default is 0, meaning always stale, which is why people see more requests than they expect.

gcTime is how long unused data stays in memory after the last component using it unmounts.

Tune staleTime to how often the data really changes. A product catalogue might use five minutes. A live price feed uses zero.

Mutations

import { useMutation, useQueryClient } from '@tanstack/react-query';
function AddProduct() {
  const queryClient = useQueryClient();
  const mutation = useMutation({
    mutationFn: (product) =>
      fetch('/api/products', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(product)
      }).then((r) => {
        if (!r.ok) throw new Error('Failed');
        return r.json();
      }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['products'] });
    }
  });
  return (
    <button
      onClick={() => mutation.mutate({ name: 'New product' })}
      disabled={mutation.isPending}
    >
      {mutation.isPending ? 'Adding...' : 'Add'}
    </button>
  );
}

invalidateQueries marks matching queries stale, so any mounted component refetches. That is how the list updates itself after an add.

Optimistic updates

const mutation = useMutation({
  mutationFn: toggleTodo,
  onMutate: async (todoId) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] });
    const previous = queryClient.getQueryData(['todos']);
    queryClient.setQueryData(['todos'], (old) =>
      old.map((t) => (t.id === todoId ? { ...t, done: !t.done } : t))
    );
    return { previous };
  },
  onError: (error, todoId, context) => {
    queryClient.setQueryData(['todos'], context.previous);
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  }
});

Four steps: cancel in flight refetches, snapshot the old data, apply the change, and restore the snapshot if it fails. onSettled refetches either way so the cache matches the server.

More ceremony than useOptimistic, and more control.

Dependent queries

const { data: user } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId)
});
const { data: posts } = useQuery({
  queryKey: ['posts', user?.id],
  queryFn: () => fetchPosts(user.id),
  enabled: Boolean(user?.id)
});

enabled holds the second query until the first has produced an id.

Pagination and infinite lists

const { data } = useQuery({
  queryKey: ['products', page],
  queryFn: () => fetchProducts(page),
  placeholderData: (previous) => previous
});

placeholderData keeps the previous page visible while the next loads, instead of flashing a spinner.

import { useInfiniteQuery } from '@tanstack/react-query';
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
  queryKey: ['products'],
  queryFn: ({ pageParam }) => fetchProducts(pageParam),
  initialPageParam: 1,
  getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined
});

With Suspense

import { useSuspenseQuery } from '@tanstack/react-query';
function ProductList() {
  const { data } = useSuspenseQuery({
    queryKey: ['products'],
    queryFn: fetchProducts
  });
  return <ul>{data.map((p) => <li key={p.id}>{p.name}</li>)}</ul>;
}

No isLoading and no error check. The nearest Suspense boundary handles loading and the error boundary handles failure. data is guaranteed to exist.

Do you still need it?

With a framework, Server Components and loaders cover much of this. The data is fetched on the server and revalidated after mutations, so you may not need a client cache at all.

TanStack Query still earns its place when:

  • You are building a client rendered app, such as a Vite dashboard.
  • The same data appears across many routes and should be cached in the browser.
  • You need polling, offline support or optimistic updates with fine control.
  • The app is highly interactive, with data changing constantly.

For a mostly static content site with a framework, you probably do not need it.

Quick recap

  • Server state needs caching and revalidation, which client state does not.
  • queryKey identifies cached data, so include every dependency.
  • queryFn must throw on failure.
  • staleTime controls freshness, gcTime controls memory.
  • You get deduplication, retries and refetch on focus for free.
  • Mutations invalidate query keys to refresh the interface.
  • enabled gates dependent queries, placeholderData avoids page flashes.
  • With Server Components you may not need it at all.
script.js

import {
  useQuery, useMutation, useQueryClient, useSuspenseQuery
} from '@tanstack/react-query';
function ProductList({ category }) {
  const { data, isLoading, isFetching, error } = useQuery({
    // EVERY dependency must be in the key, or you serve stale data when
    // the category changes. This is the commonest mistake.
    queryKey: ['products', category],
    queryFn: async () => {
      const response = await fetch('/api/products?category=' + category);
      // queryFn must THROW on failure, or the query looks successful
      if (!response.ok) throw new Error('Failed to load products');
      return response.json();
    },
    staleTime: 5 * 60 * 1000,      // fresh for 5 minutes, no refetch
    gcTime: 10 * 60 * 1000         // kept in memory 10 minutes after unmount
  });
  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>{error.message}</p>;
  // isFetching is true on a background refetch, so dim rather than blank
  return (
    <ul style={{ opacity: isFetching ? 0.6 : 1 }}>
      {data.map((p) => <li key={p.id}>{p.name}</li>)}
    </ul>
  );
}
function AddProduct() {
  const queryClient = useQueryClient();
  const mutation = useMutation({
    mutationFn: (product) =>
      fetch('/api/products', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(product)
      }).then((r) => {
        if (!r.ok) throw new Error('Failed');
        return r.json();
      }),
    // Invalidating marks the list stale, so it refetches by itself
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['products'] })
  });
  return (
    <button onClick={() => mutation.mutate({ name: 'New' })}
            disabled={mutation.isPending}>
      {mutation.isPending ? 'Adding...' : 'Add'}
    </button>
  );
}
// OPTIMISTIC UPDATE: cancel, snapshot, apply, roll back on error
function useToggleTodo() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: toggleTodo,
    onMutate: async (todoId) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });
      const previous = queryClient.getQueryData(['todos']);
      queryClient.setQueryData(['todos'], (old) =>
        old.map((t) => (t.id === todoId ? { ...t, done: !t.done } : t))
      );
      return { previous };
    },
    onError: (error, id, context) =>
      queryClient.setQueryData(['todos'], context.previous),
    onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] })
  });
}
// DEPENDENT queries: hold the second until the first has an id
function UserPosts({ userId }) {
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId)
  });
  const { data: posts } = useQuery({
    queryKey: ['posts', user?.id],
    queryFn: () => fetchPosts(user.id),
    enabled: Boolean(user?.id)
  });
  return <p>{posts?.length ?? 0} posts</p>;
}
// WITH SUSPENSE: no isLoading, no error check, data always exists
function SuspenseList() {
  const { data } = useSuspenseQuery({
    queryKey: ['products'],
    queryFn: fetchProducts
  });
  return <ul>{data.map((p) => <li key={p.id}>{p.name}</li>)}</ul>;
}

Was this explanation helpful?

Your feedback helps us improve the content for everyone.