On this page

Data Fetching with TanStack Query: Caching, Mutations, and Pagination

14 min read TextCh. 4 — Data and Forms

Why Not Just useEffect for Data Fetching?

Manual data fetching with useEffect works but leaves you responsible for:

  • Managing loading, error, and data states manually
  • Cancelling stale requests on unmount
  • Caching and deduplicating concurrent requests
  • Background refetching when the user returns to the tab
  • Synchronizing data across multiple components
  • Retry logic on failure
  • Pagination and infinite scroll

TanStack Query (formerly React Query) handles all of these concerns with a few lines of code, making it the de facto standard for server state management in React applications.

Installation

npm install @tanstack/react-query
# Optional: Devtools
npm install @tanstack/react-query-devtools

Setup

Create a QueryClient at module level and provide it at the app root:

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5 minutes — data is fresh for 5 min
      retry: 2,                  // retry failed requests twice
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

useQuery — Fetching Data

useQuery fetches, caches, and manages server state:

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

interface Product {
  id: number;
  title: string;
  price: number;
  image: string;
}

async function fetchProduct(id: number): Promise<Product> {
  const res = await fetch(`https://fakestoreapi.com/products/${id}`);
  if (!res.ok) throw new Error(`Product ${id} not found`);
  return res.json() as Promise<Product>;
}

function ProductDetail({ productId }: { productId: number }) {
  const {
    data: product,
    isLoading,
    isError,
    error,
    isFetching,         // true during background refetch
    isStale,            // true when data is older than staleTime
  } = useQuery({
    queryKey: ['product', productId],
    queryFn: () => fetchProduct(productId),
    enabled: productId > 0, // only run when productId is valid
  });

  if (isLoading) return <p>Loading product...</p>;
  if (isError) return <p role="alert">Error: {(error as Error).message}</p>;
  if (!product) return null;

  return (
    <article>
      {isFetching && <span className="refresh-indicator">Refreshing...</span>}
      <img src={product.image} alt={product.title} width={200} height={200} />
      <h1>{product.title}</h1>
      <p>${product.price}</p>
    </article>
  );
}

useMutation — Creating, Updating, Deleting

Mutations handle data modifications. They include lifecycle callbacks for optimistic updates, error rollback, and cache invalidation:

import { useMutation, useQueryClient } from '@tanstack/react-query';

interface NewProduct {
  title: string;
  price: number;
  category: string;
}

async function createProduct(product: NewProduct): Promise<Product> {
  const res = await fetch('https://fakestoreapi.com/products', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(product),
  });
  if (!res.ok) throw new Error('Failed to create product');
  return res.json() as Promise<Product>;
}

function AddProductForm() {
  const queryClient = useQueryClient();
  const [title, setTitle] = useState('');
  const [price, setPrice] = useState('');

  const { mutate, isPending, isError, error } = useMutation({
    mutationFn: createProduct,
    onSuccess: () => {
      // Invalidate and refetch the products list
      queryClient.invalidateQueries({ queryKey: ['products'] });
    },
  });

  function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    mutate({ title, price: Number(price), category: 'electronics' });
  }

  return (
    <form onSubmit={handleSubmit}>
      {isError && <p role="alert">Error: {(error as Error).message}</p>}
      <input
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Product title"
        required
      />
      <input
        type="number"
        value={price}
        onChange={(e) => setPrice(e.target.value)}
        placeholder="Price"
        required
      />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Adding...' : 'Add product'}
      </button>
    </form>
  );
}

Query Invalidation

After a mutation, you typically want to refetch affected queries. Use invalidateQueries to mark queries as stale and trigger a background refetch:

const queryClient = useQueryClient();

// Invalidate all queries with key starting with 'products'
queryClient.invalidateQueries({ queryKey: ['products'] });

// Invalidate a specific product
queryClient.invalidateQueries({ queryKey: ['product', 42] });

// Refetch immediately (instead of lazily on next access)
queryClient.refetchQueries({ queryKey: ['products'] });

Prefetching Data

Prefetch data before the user navigates to it — for example, on hover over a link:

function ProductLink({ productId, title }: { productId: number; title: string }) {
  const queryClient = useQueryClient();

  function prefetch() {
    queryClient.prefetchQuery({
      queryKey: ['product', productId],
      queryFn: () => fetchProduct(productId),
      staleTime: 1000 * 60 * 10, // keep prefetched data fresh for 10 minutes
    });
  }

  return (
    <a
      href={`/products/${productId}`}
      onMouseEnter={prefetch}
      onFocus={prefetch}
    >
      {title}
    </a>
  );
}

Dependent Queries

Some queries depend on the result of another. Use the enabled option:

function UserPosts({ userId }: { userId: string | null }) {
  // First query: get user details
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId!),
    enabled: !!userId,
  });

  // Second query: depends on user.email from the first query
  const { data: posts } = useQuery({
    queryKey: ['posts', user?.email],
    queryFn: () => fetchPostsByEmail(user!.email),
    enabled: !!user?.email, // only runs after user is fetched
  });

  return (
    <div>
      <h2>{user?.name}</h2>
      <ul>{posts?.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
    </div>
  );
}

Infinite Queries

For infinite scroll, use useInfiniteQuery:

import { useInfiniteQuery } from '@tanstack/react-query';

function InfinitePostList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['posts', 'infinite'],
    queryFn: ({ pageParam }) => fetchPosts(pageParam as number),
    initialPageParam: 1,
    getNextPageParam: (lastPage, allPages) =>
      lastPage.length < 10 ? undefined : allPages.length + 1,
  });

  const allPosts = data?.pages.flat() ?? [];

  return (
    <div>
      <ul>
        {allPosts.map((post) => <li key={post.id}>{post.title}</li>)}
      </ul>
      <button
        type="button"
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage ? 'Loading more...' : hasNextPage ? 'Load more' : 'All loaded'}
      </button>
    </div>
  );
}

In the next lesson, you will combine TanStack Query with Suspense and Error Boundaries for a declarative loading and error handling experience.

queryKey is the cache key — treat it like a dependency array
Everything that affects the data fetched must be in the `queryKey`. If your query depends on `userId`, `page`, and `filter`, include all three: `queryKey: ['users', userId, { page, filter }]`. TanStack Query automatically refetches when any key element changes.
staleTime vs gcTime
`staleTime` controls how long data is considered fresh (no background refetch). `gcTime` (formerly `cacheTime`) controls how long inactive query data stays in memory before being garbage collected. A common production setup is `staleTime: 60_000` (1 min) and `gcTime: 300_000` (5 min).
Wrap your app with QueryClientProvider once
Create the `QueryClient` instance **outside** the component tree (at module level) so it is not recreated on every render. Place `QueryClientProvider` at the root of your app — usually in `main.tsx`. Creating multiple `QueryClient` instances defeats the shared cache.
import {
  useQuery,
  useMutation,
  useQueryClient,
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query';

interface Post {
  id: number;
  title: string;
  userId: number;
}

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,   // 5 minutes
      gcTime: 1000 * 60 * 10,      // 10 minutes
      retry: 2,
    },
  },
});

async function fetchPosts(): Promise<Post[]> {
  const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=10');
  if (!res.ok) throw new Error('Failed to fetch posts');
  return res.json() as Promise<Post[]>;
}

async function createPost(title: string): Promise<Post> {
  const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title, userId: 1 }),
  });
  if (!res.ok) throw new Error('Failed to create post');
  return res.json() as Promise<Post>;
}

function PostList() {
  const qc = useQueryClient();

  const { data: posts, isLoading, isError, error } = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  });

  const mutation = useMutation({
    mutationFn: createPost,
    onSuccess: (newPost) => {
      // Optimistic cache update
      qc.setQueryData<Post[]>(['posts'], (old) =>
        old ? [newPost, ...old] : [newPost]
      );
    },
    onError: () => {
      qc.invalidateQueries({ queryKey: ['posts'] });
    },
  });

  if (isLoading) return <p>Loading posts...</p>;
  if (isError) return <p role="alert">Error: {(error as Error).message}</p>;

  return (
    <div>
      <button
        type="button"
        onClick={() => mutation.mutate('New Post Title')}
        disabled={mutation.isPending}
      >
        {mutation.isPending ? 'Creating...' : 'Create Post'}
      </button>

      <ul>
        {posts?.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

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