Skip to main content
📝 Technical Article

How to Use TanStack Query in React: A Complete Guide

Billie Heidelberg Jr.
Billie Heidelberg Jr.
Full Stack Developer
37 min read
Cover image for How to Use TanStack Query in React: A Complete Guide

How to Use TanStack Query in React: A Complete Guide

Table of Contents (Time: ~100 minutes total)

  1. What is TanStack Query? - 5 min
  2. The Mental Model Shift - 5 min
  3. Quick Start: Your First Query - 10 min
  4. Why Use TanStack Query? - 10 min
  5. Core Concepts Explained - 15 min
  6. Basic Usage Patterns - 15 min
  7. Mutations & Data Updates - 15 min
  8. Advanced Features - 25 min (includes SSR/Hydration)
  9. Migration Strategy - 10 min
  10. Performance Impact - 10 min

What is TanStack Query?

TL;DR: TanStack Query is a smart caching layer between your React UI and your backend that eliminates repetitive fetch logic and automatically keeps your data fresh.

Think of it as:

  • 🧠 A memory system for your app
  • 🔄 Automatic data synchronization
  • ⚡ Instant page loads from cache
  • 🎯 Zero boilerplate loading states

In one sentence: Instead of manually fetching data with useEffect and managing loading states, you declare what data you need and TanStack Query handles everything else.

Who this guide is for:

  • React developers comfortable with hooks and REST APIs
  • Teams dealing with excessive API calls and backend costs
  • Apps with loading state boilerplate everywhere
  • Anyone wanting significant reduction in API costs

Note: This guide covers TanStack Query v5, the current major version. Some patterns differ from v4 — key differences are called out where relevant.


The Mental Model Shift

Before diving into code, this is the single most important concept to internalize:

Old thinking: "Fetch data when component mounts" ✅ New thinking: "Describe what data I need, the library handles when to fetch"

This shift from imperative to declarative is the key to TanStack Query's power. You stop telling React how and when to fetch data, and instead declare what data a component needs. TanStack Query figures out the rest — caching, deduplication, background refetching, error retries, and stale data management.

Once this clicks, everything else in this guide will feel intuitive.


Quick Start: Your First Query in 2 Minutes

Step 1: Install and Setup (30 seconds)

npm install @tanstack/react-query
// App.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient();

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

Step 2: Replace Your useEffect Fetching (90 seconds)

Old way (useEffect + useState):

function ProductList() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/products')
      .then(res => res.json())
      .then(data => setProducts(data))
      .catch(err => setError(err))
      .finally(() => setLoading(false));
  }, []);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error!</div>;
  return <div>{/* render products */}</div>;
}

New way (useQuery):

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

function ProductList() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['products'],
    queryFn: () => fetch('/api/products').then(r => r.json())
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error!</div>;
  return <div>{/* render data */}</div>;
}

🎉 That's it! You now have:

  • ✅ Automatic caching (navigation back = instant load)
  • ✅ Background refetching (data stays fresh)
  • ✅ No race conditions (old requests canceled)
  • ✅ Global state (all components share data)

💡 Quick Test: Navigate to the page, then navigate away and back. The second load should be instant (0ms) with no loading spinner!

Try It Live — CodeSandbox: Basic useQuery Example


Why Use TanStack Query?

The 6 Problems with Traditional Fetching

Problem Impact TanStack Query Solution
No cache Re-fetch same data repeatedly Smart caching with configurable freshness
Race conditions Wrong data displayed Automatic request cancellation
Stale data Users see outdated info Background refetching
Manual state Hundreds of lines of boilerplate Automatic loading/error management
Duplicate requests 3 components = 3 API calls Request deduplication
Prop drilling Complex state sharing Global cache accessible anywhere

Real-World Impact

Before TanStack Query:

  • 🔴 User browses 3 products: 21 API calls, 3.4 seconds
  • 🔴 Every page navigation: full reload with spinner
  • 🔴 Monthly AWS costs: $300

After TanStack Query:

  • 🟢 User browses 3 products: 11 API calls (48% reduction), 1.2 seconds
  • 🟢 Cached pages: instant (0-50ms)
  • 🟢 Monthly AWS costs: $80 (73% savings)

Transparency note: These numbers are based on a mid-size e-commerce app with ~10K monthly users. Your results will vary depending on navigation patterns, cache hit rates, and data volatility. The key takeaway is that caching eliminates redundant requests, and the savings scale with traffic.

See full performance metrics →


Core Concepts Explained

1. Query Keys: Your Cache Address

Analogy: Query keys are like URLs for your cache.

// Different query keys = different cache entries
['products']              // All products
['products', 'featured']  // Only featured products
['product', 123]          // Product with ID 123
['user', userId]          // User-specific data

Rules:

  • Must be an array
  • Must be unique for each piece of data
  • Include dynamic values in the key: ['product', productId]
  • Order matters: ['a', 'b'] ≠ ['b', 'a']

⚠️ Common Mistake: Using the same query key for different data causes cache collisions. Always include dynamic values like IDs in your query keys!

💡 Pro Tip: Create key factories to stay organized:

export const productKeys = {
  all: ['products'] as const,
  lists: () => [...productKeys.all, 'list'] as const,
  list: (filters: string) => [...productKeys.lists(), { filters }] as const,
  details: () => [...productKeys.all, 'detail'] as const,
  detail: (id: number) => [...productKeys.details(), id] as const,
}

2. Stale Time: How Long Data is "Fresh"

Analogy: Like food freshness labels.

staleTime: 5 * 60 * 1000 // Fresh for 5 minutes

What happens:

  • Data < staleTime: Use cached data instantly, no refetch
  • Data > staleTime: Use cached data immediately, refetch in background

Decision Tree: What staleTime should I use?

├─ Real-time data (stock prices)?       → 0-30 seconds
├─ Frequently changing (news feed)?     → 1-5 minutes
├─ Moderate updates (product listings)? → 5-15 minutes
└─ Rarely changing (user profile)?      → 15-30 minutes

⚠️ Common Mistake: Setting staleTime: 0 (default) means data is always stale, causing constant refetches on every component mount.

3. Cache Time (gcTime): How Long Unused Data Stays in Memory

Analogy: Like a refrigerator — keeps food longer than it's fresh.

gcTime: 5 * 60 * 1000 // Keep in memory for 5 minutes after last use

v5 rename: This was called cacheTime in v4. It's now gcTime (garbage collection time) in v5.

What happens:

  1. Component unmounts → Data marked as "inactive"
  2. After gcTime passes → Data removed from memory (garbage collected)
  3. If component remounts within gcTime → Instant cached data

The Relationship: staleTime < gcTime (always)

Example: staleTime: 5 minutes, gcTime: 30 minutes

Timeline:
0 min  → Data fetched (fresh)
5 min  → Data becomes stale (but still cached)
30 min → Data removed from memory

💡 Pro Tip: Set gcTime at least 2x longer than staleTime to ensure smooth navigation. Example: staleTime: 5min, gcTime: 10-15min.

4. Background Refetching: Keeping Data Fresh

TanStack Query automatically refetches stale data when:

  • ✅ Window regains focus (user comes back to tab)
  • ✅ Network reconnects
  • ✅ Query is mounted (if stale)
  • ✅ Manual refetch interval (if configured)

You can control this:

useQuery({
  queryKey: ['products'],
  queryFn: fetchProducts,
  staleTime: 5 * 60 * 1000,
  refetchOnWindowFocus: true,   // Default: true
  refetchOnReconnect: true,     // Default: true
  refetchInterval: 30000,       // Poll every 30s (optional)
})

Basic Usage Patterns

Pattern 1: Simple Data Fetching

Basic Version:

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

function Products() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['products'],
    queryFn: () => fetch('/api/products').then(r => r.json())
  });

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

  return (
    <ul>
      {data.map(product => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

Production Version (with TypeScript & error handling):

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

interface Product {
  id: number;
  name: string;
  price: number;
}

async function fetchProducts(): Promise<Product[]> {
  const res = await fetch('/api/products');
  if (!res.ok) throw new Error('Failed to fetch products');
  return res.json();
}

function Products() {
  const { data, isLoading, error, refetch } = useQuery({
    queryKey: ['products'],
    queryFn: fetchProducts,
    staleTime: 5 * 60 * 1000, // 5 minutes
  });

  if (isLoading) return <LoadingSpinner />;
  if (error) {
    return (
      <ErrorMessage
        message={error.message}
        onRetry={() => refetch()}
      />
    );
  }

  return (
    <div>
      <h2>Products ({data.length})</h2>
      <ul>
        {data.map(product => (
          <li key={product.id}>
            {product.name} - ${product.price}
          </li>
        ))}
      </ul>
    </div>
  );
}

Pattern 2: Dynamic Query Keys (with Parameters)

When to use: Fetching data based on props/params (product detail, user profile, search results).

function ProductDetail({ productId }: { productId: number }) {
  const { data, isLoading } = useQuery({
    queryKey: ['product', productId], // ← Include ID in key
    queryFn: () => fetch(`/api/products/${productId}`).then(r => r.json()),
    staleTime: 10 * 60 * 1000, // 10 minutes
  });

  if (isLoading) return <div>Loading...</div>;
  return <div>{data.name}</div>;
}

💡 Pro Tip: The query key automatically updates when productId changes, triggering a new fetch.

Pattern 3: Conditional Queries (enabled)

When to use: Don't fetch until certain conditions are met (user is logged in, permission granted, dependency loaded).

function UserProfile({ userId }: { userId: number | null }) {
  const { data } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
    enabled: !!userId, // ← Only fetch when userId exists
    staleTime: 15 * 60 * 1000,
  });

  if (!userId) return <div>Please log in</div>;
  if (!data) return <div>Loading...</div>;
  return <div>Welcome, {data.name}!</div>;
}

Pattern 4: Dependent Queries (wait for one, then fetch another)

When to use: Second query needs data from first query.

function UserPosts({ userId }: { userId: number }) {
  // First: Get user details
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
  });

  // Second: Get user's posts (only when we have user data)
  const { data: posts } = useQuery({
    queryKey: ['posts', user?.id],
    queryFn: () => fetch(`/api/posts?userId=${user.id}`).then(r => r.json()),
    enabled: !!user, // ← Wait for user to load
  });

  if (!user) return <div>Loading user...</div>;
  if (!posts) return <div>Loading posts...</div>;

  return (
    <div>
      <h2>{user.name}'s Posts</h2>
      <ul>
        {posts.map(post => <li key={post.id}>{post.title}</li>)}
      </ul>
    </div>
  );
}

Pattern 5: Parallel Queries

When to use: Fetch multiple independent data sources at once.

function Dashboard() {
  const products = useQuery({
    queryKey: ['products'],
    queryFn: fetchProducts,
  });

  const categories = useQuery({
    queryKey: ['categories'],
    queryFn: fetchCategories,
  });

  const stats = useQuery({
    queryKey: ['stats'],
    queryFn: fetchStats,
  });

  // Check if ALL queries are loading
  if (products.isLoading || categories.isLoading || stats.isLoading) {
    return <div>Loading dashboard...</div>;
  }

  return (
    <div>
      <ProductList data={products.data} />
      <CategoryNav data={categories.data} />
      <StatsPanel data={stats.data} />
    </div>
  );
}

Alternative using useQueries:

function Dashboard() {
  const results = useQueries({
    queries: [
      { queryKey: ['products'], queryFn: fetchProducts },
      { queryKey: ['categories'], queryFn: fetchCategories },
      { queryKey: ['stats'], queryFn: fetchStats },
    ],
  });

  const isLoading = results.some(result => result.isLoading);
  const [products, categories, stats] = results.map(r => r.data);

  if (isLoading) return <div>Loading...</div>;
  return <div>{/* render all data */}</div>;
}

Mutations & Data Updates

Understanding Mutations

Decision Tree:

  • Reading data? → useQuery
  • Changing data? → useMutation

What mutations do:

  • POST, PUT, PATCH, DELETE requests
  • Update server state
  • Invalidate cached queries (trigger refetch)
  • Optimistically update UI (instant feedback)

⚠️ Critical: Always invalidate related queries after mutations! Forgetting this means users see stale data until cache expires.

Pattern 1: Basic Mutation

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

function AddProduct() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: (newProduct: Product) => {
      return fetch('/api/products', {
        method: 'POST',
        body: JSON.stringify(newProduct),
        headers: { 'Content-Type': 'application/json' },
      }).then(r => r.json());
    },
    onSuccess: () => {
      // Invalidate and refetch products list
      queryClient.invalidateQueries({ queryKey: ['products'] });
    },
  });

  return (
    <button
      onClick={() => mutation.mutate({ name: 'New Product', price: 99 })}
      disabled={mutation.isPending}
    >
      {mutation.isPending ? 'Adding...' : 'Add Product'}
    </button>
  );
}

Pattern 2: Optimistic Updates (Instant UI Feedback)

When to use: Make UI feel instant while waiting for server confirmation.

When NOT to use: Operations where the server might legitimately reject the request — payments, inventory checks, permission-dependent actions, or anything where rollback would confuse the user. For these, wait for server confirmation before updating the UI.

function DeleteProduct({ productId }: { productId: number }) {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: (id: number) => {
      return fetch(`/api/products/${id}`, { method: 'DELETE' });
    },

    // Before mutation runs
    onMutate: async (deletedId) => {
      // Cancel any outgoing refetches
      await queryClient.cancelQueries({ queryKey: ['products'] });

      // Snapshot current data (for rollback)
      const previousProducts = queryClient.getQueryData(['products']);

      // Optimistically update UI
      queryClient.setQueryData(['products'], (old: Product[]) => {
        return old.filter(product => product.id !== deletedId);
      });

      // Return snapshot for rollback
      return { previousProducts };
    },

    // If mutation fails, rollback
    onError: (err, deletedId, context) => {
      queryClient.setQueryData(['products'], context.previousProducts);
      alert('Failed to delete product');
    },

    // Always refetch after success or error
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['products'] });
    },
  });

  return (
    <button onClick={() => mutation.mutate(productId)}>
      Delete
    </button>
  );
}

What happens:

  1. User clicks "Delete"
  2. UI updates instantly (product removed)
  3. Server request sent in background
  4. If successful: UI already updated, done!
  5. If failed: Rollback to previous state, show error

Pattern 3: Invalidation Strategies

// Invalidate exact match
queryClient.invalidateQueries({ queryKey: ['products'] });

// Invalidate all product-related queries
queryClient.invalidateQueries({ queryKey: ['products'], exact: false });
// Invalidates: ['products'], ['products', 'featured'], ['product', 123]

// Invalidate multiple query keys
await Promise.all([
  queryClient.invalidateQueries({ queryKey: ['products'] }),
  queryClient.invalidateQueries({ queryKey: ['categories'] }),
]);

// Invalidate and refetch immediately
queryClient.invalidateQueries({
  queryKey: ['products'],
  refetchType: 'active' // Only refetch mounted queries
});

💡 Pro Tip: Use key factories for consistent invalidation:

// After updating a product
queryClient.invalidateQueries({ queryKey: productKeys.detail(productId) });
queryClient.invalidateQueries({ queryKey: productKeys.lists() });

Pattern 4: Form Submission with Validation

function ProductForm() {
  const [name, setName] = useState('');
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: (product: Product) => {
      return fetch('/api/products', {
        method: 'POST',
        body: JSON.stringify(product),
        headers: { 'Content-Type': 'application/json' },
      }).then(async res => {
        if (!res.ok) {
          const error = await res.json();
          throw new Error(error.message);
        }
        return res.json();
      });
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['products'] });
      setName(''); // Reset form
    },
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    mutation.mutate({ name, price: 99 });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
        placeholder="Product name"
      />
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? 'Saving...' : 'Add Product'}
      </button>
      {mutation.isError && (
        <div className="error">{mutation.error.message}</div>
      )}
    </form>
  );
}

Advanced Features

1. Prefetching (Make Navigation Instant)

When to use: You know what user will click next (product links, pagination).

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

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

  const prefetchProduct = () => {
    queryClient.prefetchQuery({
      queryKey: ['product', productId],
      queryFn: () => fetch(`/api/products/${productId}`).then(r => r.json()),
      staleTime: 5 * 60 * 1000,
    });
  };

  return (
    <Link to={`/products/${productId}`} onMouseEnter={prefetchProduct}>
      View Product
    </Link>
  );
}

Result: When user clicks, data is already cached = instant page load!

2. Infinite Queries (Infinite Scroll / Load More)

When to use: Pagination, infinite scroll, "Load More" buttons.

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

interface ProductsPage {
  products: Product[];
  nextCursor: number | null;
}

function InfiniteProducts() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['products', 'infinite'],
    queryFn: async ({ pageParam }) => {
      const res = await fetch(`/api/products?cursor=${pageParam}`);
      return res.json() as Promise<ProductsPage>;
    },
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    initialPageParam: 0,
  });

  return (
    <div>
      {data?.pages.map((page, i) => (
        <div key={i}>
          {page.products.map(product => (
            <div key={product.id}>{product.name}</div>
          ))}
        </div>
      ))}

      {hasNextPage && (
        <button
          onClick={() => fetchNextPage()}
          disabled={isFetchingNextPage}
        >
          {isFetchingNextPage ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  );
}

3. Suspense Integration (React 18+)

When to use: Want to use React Suspense boundaries for loading states.

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

function ProductDetail({ id }: { id: number }) {
  // No isLoading check needed - Suspense handles it
  const { data } = useSuspenseQuery({
    queryKey: ['product', id],
    queryFn: () => fetch(`/api/products/${id}`).then(r => r.json()),
  });

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

// Wrap with Suspense in parent
function ProductPage({ id }: { id: number }) {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <ProductDetail id={id} />
    </Suspense>
  );
}

React 18 Compatible: TanStack Query works seamlessly with React 18's concurrent rendering and Suspense. This makes it ideal for modern frameworks like Next.js App Router, Remix, and React Server Components.

4. Query Retries & Error Handling

useQuery({
  queryKey: ['products'],
  queryFn: fetchProducts,
  retry: 3,          // Retry 3 times on failure
  retryDelay: attemptIndex => {
    return Math.min(1000 * 2 ** attemptIndex, 30000); // Exponential backoff
  },
  staleTime: 5 * 60 * 1000,
})

v5 Note: The onError and onSuccess callbacks were removed from useQuery in v5. Handle errors in the component using the error return value, or use the global QueryCache callbacks for logging/tracking:

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error) => {
      console.error('Query failed:', error);
      // Send to error tracking service
    },
  }),
});

5. Initial Data (Avoid First Load)

When to use: You already have data from SSR or initial page load.

function ProductDetail({ id, initialData }: Props) {
  const { data } = useQuery({
    queryKey: ['product', id],
    queryFn: () => fetch(`/api/products/${id}`).then(r => r.json()),
    initialData: initialData,         // Use this on first render
    staleTime: 5 * 60 * 1000,        // Mark as fresh for 5 min
  });

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

6. Placeholder Data (Show Something Immediately)

When to use: Show approximate/cached data while fetching fresh data.

function ProductDetail({ id }: { id: number }) {
  const queryClient = useQueryClient();

  const { data } = useQuery({
    queryKey: ['product', id],
    queryFn: () => fetch(`/api/products/${id}`).then(r => r.json()),
    placeholderData: () => {
      // Use data from products list as placeholder
      const products = queryClient.getQueryData(['products']) as Product[];
      return products?.find(p => p.id === id);
    },
  });

  return <div>{data?.name || 'Loading...'}</div>;
}

7. SSR & Hydration (Next.js, Remix, Server Components)

When to use: Server-side rendering or using React Server Components.

The Problem: On SSR, you fetch data on the server, but the client doesn't know about it and refetches unnecessarily.

The Solution: Prefetch on the server, dehydrate the cache, and hydrate it on the client so queries resolve instantly without redundant network requests.

Option 1: Using initialData (Simple)

Best for one or two queries on a page.

// Next.js App Router — Server Component
async function ProductPage({ params }: { params: { id: string } }) {
  // Fetch on server
  const serverData = await fetch(
    `https://api.example.com/products/${params.id}`
  ).then(r => r.json());

  return <ProductClient id={Number(params.id)} initialData={serverData} />;
}

// Client component
'use client';
function ProductClient({ id, initialData }: Props) {
  const { data } = useQuery({
    queryKey: ['product', id],
    queryFn: () => fetch(`/api/products/${id}`).then(r => r.json()),
    initialData: initialData,        // Use server data on first render
    staleTime: 5 * 60 * 1000,       // Fresh for 5 minutes
  });

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

This is the v5 recommended pattern. It lets you prefetch multiple queries on the server and hydrate them all at once on the client.

// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 60 * 1000,
          },
        },
      })
  );

  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}
// app/products/page.tsx — Server Component
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query';

export default async function ProductsPage() {
  const queryClient = new QueryClient();

  // Prefetch on the server
  await queryClient.prefetchQuery({
    queryKey: ['products'],
    queryFn: fetchProducts,
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <ProductList />
    </HydrationBoundary>
  );
}
// components/ProductList.tsx — Client Component
'use client';
import { useQuery } from '@tanstack/react-query';

export function ProductList() {
  // This query is already prefetched — no loading state on initial render
  const { data } = useQuery({
    queryKey: ['products'],
    queryFn: fetchProducts,
  });

  return (
    <ul>
      {data?.map(product => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

Benefits:

  • ✅ No loading state on initial page load
  • ✅ Data available immediately on client
  • ✅ Automatic refetching when stale
  • ✅ Works with streaming SSR
  • ✅ Scales to any number of queries per page

💡 Pro Tip: For Next.js App Router, use initialData for simple single-query pages. For pages with multiple queries or nested components that each need data, use the HydrationBoundary + dehydrate pattern — it's more composable and avoids prop drilling server data.

Resources:


Migration Strategy

Phase 1: Setup (15 minutes)

# 1. Install
npm install @tanstack/react-query @tanstack/react-query-devtools
// 2. Wrap app
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000,     // 5 min default
      gcTime: 10 * 60 * 1000,       // 10 min default
      retry: 1,
      refetchOnWindowFocus: false,   // Disable for testing
    },
  },
});

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

Phase 2: Migrate High-Traffic Pages (1-2 hours per page)

Priority order:

  1. ✅ Home page / Dashboard (biggest impact)
  2. ✅ List pages (product list, user list)
  3. ✅ Detail pages (with prefetching)
  4. ✅ Forms with mutations
  5. ✅ Everything else

Migration checklist per page:

// Before
function OldProductList() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetchProducts().then(setProducts).finally(() => setLoading(false));
  }, []);
  // ...
}

// After
function NewProductList() {
  const { data: products, isLoading } = useQuery({
    queryKey: ['products'],
    queryFn: fetchProducts,
    staleTime: 5 * 60 * 1000,
  });
  // ...
}

Checklist:

  • ✅ Remove useState
  • ✅ Remove useEffect
  • ✅ Add useQuery
  • ✅ Test loading/error states
  • ✅ Verify caching works (navigate away and back)

Phase 3: Add Mutations (30 min per form)

// Before
const handleSubmit = async (data) => {
  setLoading(true);
  try {
    await createProduct(data);
    await refetchProducts(); // Manual refetch
  } finally {
    setLoading(false);
  }
};

// After
const mutation = useMutation({
  mutationFn: createProduct,
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['products'] });
  },
});

const handleSubmit = (data) => {
  mutation.mutate(data);
};

Phase 4: Optimize (1-2 hours)

  • Add prefetching to links
  • Tune stale times based on data update frequency
  • Add optimistic updates to critical interactions (where rollback is unlikely)
  • Implement placeholderData for better UX
  • Remove old data fetching code

Measuring Success

Before migration, track:

const baseline = {
  apiCallsPerSession: 45,
  avgLoadTime: 1200, // ms
  monthlyAPICalls: 450000,
  monthlyCost: 300, // USD
};

After migration, compare:

const after = {
  apiCallsPerSession: 12,
  avgLoadTime: 300,
  monthlyAPICalls: 120000,
  monthlyCost: 80,
};

Performance Impact

Real Production Metrics

Scenario: E-commerce site, ~10,000 monthly users

Metric Before After Improvement
Page Load Time 1.2s 0.3s 75% faster
API Calls/Session 45 12 73% reduction
Monthly API Calls 450,000 120,000 330,000 fewer
AWS Costs $300/mo $80/mo $220 saved/mo

These are illustrative numbers from a single mid-size app. Your mileage will vary based on navigation depth, cache hit rates, and how much redundant fetching your current code does. The pattern holds: caching reduces redundant calls, and the more users navigate, the bigger the savings.

Cost Savings Calculation

// AWS API Gateway: ~$3.50 per million requests
// Lambda: ~$0.20 per million requests

Before:
- 450,000 API calls/month
- Gateway: $1.58 + Lambda costs
- Total: ~$300/month

After:
- 120,000 API calls/month
- Gateway: $0.42 + Lambda costs
- Total: ~$80/month

Annual Savings: $2,640
Implementation Time: 2 weeks
ROI: Immediate (first month)

User Experience Impact

Navigation flow example:

Action Before (no cache) After (with cache)
View home page 5 API calls (1s) 5 API calls (1s)
Click product 3 API calls (800ms) 3 API calls (800ms)
Click back 5 API calls (1s) 😢 0 API calls (instant!) 🎉
Click another product 3 API calls (800ms) 0-3 calls (instant if prefetched)
Click back 5 API calls (1s) 😢 0 API calls (instant!) 🎉

Total: 21 API calls → 8-11 API calls = 48-62% reduction


Troubleshooting

"Why isn't my data refetching?"

Check:

  • Is staleTime too long? (data not marked stale yet)
  • Is refetchOnWindowFocus disabled?
  • Is the query enabled: false?
  • Did you forget to invalidate after mutation?
// Debug stale time
const { data, isStale } = useQuery({
  queryKey: ['products'],
  queryFn: fetchProducts,
  staleTime: 5 * 60 * 1000,
});
console.log('Is data stale?', isStale);

"Why do I see loading state when data should be cached?"

Possible causes:

  • Query key changed (new cache entry)
  • Cache expired (gcTime passed)
  • Data was invalidated
  • Different component using different query key
// Check cache contents
import { useQueryClient } from '@tanstack/react-query';

const queryClient = useQueryClient();
const cachedData = queryClient.getQueryData(['products']);
console.log('Cached data:', cachedData);

"How do I know if caching is working?"

Use DevTools:

  1. Open React Query DevTools (bottom-left icon)
  2. Navigate to a page
  3. Navigate away
  4. Navigate back
  5. Check DevTools:
    • ✅ Green = Fresh (from cache)
    • 🟡 Yellow = Stale (from cache, refetching)
    • 🔴 Red = Error

💡 Pro Tip: Open your browser's Network tab alongside React Query DevTools. If you navigate back to a page and see NO network requests but the page loads instantly, caching is working perfectly!

Or add global logging (v5 pattern):

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

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onSuccess: () => console.log('✅ Query success'),
    onError: () => console.log('❌ Query error'),
  }),
});

"Data is stale but not refetching"

Check refetch settings:

useQuery({
  queryKey: ['products'],
  queryFn: fetchProducts,
  staleTime: 5 * 60 * 1000,
  refetchOnMount: true,          // Refetch when component mounts
  refetchOnWindowFocus: true,    // Refetch when window focused
  refetchOnReconnect: true,      // Refetch when network reconnects
})

Best Practices Summary

Query Keys

  • DO: Use arrays: ['products']
  • DO: Include dynamic values: ['product', id]
  • DO: Use key factories for consistency
  • DO: Make keys unique per data piece
  • DON'T: Use strings: 'products'
  • DON'T: Forget dynamic values in key
  • DON'T: Use same key for different data

Stale Time

  • DO: Set based on data update frequency
  • DO: Use longer times for static data (15-30 min)
  • DO: Use shorter times for live data (0-60 sec)
  • DO: Balance performance vs freshness
  • DON'T: Leave as default (0) for all queries
  • DON'T: Set too long for rapidly changing data
  • DON'T: Set same value for all queries

Mutations

  • DO: Invalidate related queries
  • DO: Use optimistic updates for low-risk, instant UX
  • DO: Handle errors gracefully
  • DO: Disable buttons while pending
  • DON'T: Forget to invalidate queries
  • DON'T: Over-invalidate (refetch everything)
  • DON'T: Ignore error states
  • DON'T: Use optimistic updates for server-validated operations (payments, permissions)

Performance

  • DO: Prefetch data on hover/prediction
  • DO: Use placeholderData for instant display
  • DO: Monitor cache hit rates
  • DO: Tune stale times based on metrics
  • DON'T: Over-fetch (too aggressive refetching)
  • DON'T: Under-cache (staleTime too short)
  • DON'T: Ignore DevTools insights
  • DON'T: Skip performance tracking

Quick Reference: Common Patterns

// ────────────────────────────────────────────────────────
// BASIC QUERY
// ────────────────────────────────────────────────────────
const { data, isLoading, error } = useQuery({
  queryKey: ['key'],
  queryFn: fetchFunction,
})

// ────────────────────────────────────────────────────────
// QUERY WITH PARAMETERS
// ────────────────────────────────────────────────────────
const { data } = useQuery({
  queryKey: ['key', param],
  queryFn: () => fetchFunction(param),
})

// ────────────────────────────────────────────────────────
// CONDITIONAL QUERY
// ────────────────────────────────────────────────────────
const { data } = useQuery({
  queryKey: ['key'],
  queryFn: fetchFunction,
  enabled: !!condition,
})

// ────────────────────────────────────────────────────────
// MUTATION
// ────────────────────────────────────────────────────────
const mutation = useMutation({
  mutationFn: postFunction,
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['key'] })
  },
})

// ────────────────────────────────────────────────────────
// OPTIMISTIC UPDATE
// ────────────────────────────────────────────────────────
const mutation = useMutation({
  mutationFn: updateFunction,
  onMutate: async (newData) => {
    await queryClient.cancelQueries({ queryKey: ['key'] })
    const previous = queryClient.getQueryData(['key'])
    queryClient.setQueryData(['key'], newData)
    return { previous }
  },
  onError: (err, newData, context) => {
    queryClient.setQueryData(['key'], context.previous)
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['key'] })
  },
})

// ────────────────────────────────────────────────────────
// PREFETCH
// ────────────────────────────────────────────────────────
queryClient.prefetchQuery({
  queryKey: ['key'],
  queryFn: fetchFunction,
})

// ────────────────────────────────────────────────────────
// INFINITE QUERY
// ────────────────────────────────────────────────────────
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
  queryKey: ['key'],
  queryFn: ({ pageParam }) => fetchPage(pageParam),
  getNextPageParam: (lastPage) => lastPage.nextCursor,
  initialPageParam: 0,
})

Conclusion

TanStack Query transforms React apps from stateless UIs into data-smart applications that:

  • 🧠 Remember what they fetched
  • 🔄 Sync automatically
  • ⚡ Load instantly
  • 💰 Cost less to run

The Core Shift

From: "Fetch data when component mounts" To: "Describe what data I need, the library handles the rest"

Key Takeaways

  1. Replace useEffect with useQuery → Eliminate boilerplate
  2. Use query keys properly → Cache isolation
  3. Tune staleTime → Balance freshness vs performance
  4. Invalidate strategically → Keep data fresh
  5. Add optimistic updates → Instant UX (for appropriate operations)
  6. Prefetch intelligently → Feel instant
  7. Measure results → Prove the value

Next Steps

  • ✅ Install TanStack Query today
  • ✅ Migrate one high-traffic page
  • ✅ Measure API reduction & load times
  • ✅ Add to 2-3 more pages over next week
  • ✅ Share results with team
  • ✅ Optimize stale times based on metrics

Resources


Appendix: Full E-commerce Example

// ═══════════════════════════════════════════════════════
// PRODUCT LIST WITH PREFETCHING
// ═══════════════════════════════════════════════════════

import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';

interface Product {
  id: number;
  name: string;
  price: number;
  imageUrl: string;
}

async function fetchProducts(): Promise<Product[]> {
  const res = await fetch('/api/products');
  if (!res.ok) throw new Error('Failed to fetch products');
  return res.json();
}

async function fetchProduct(id: number): Promise<Product> {
  const res = await fetch(`/api/products/${id}`);
  if (!res.ok) throw new Error('Failed to fetch product');
  return res.json();
}

export function ProductList() {
  const queryClient = useQueryClient();

  const { data: products, isLoading, error } = useQuery({
    queryKey: ['products'],
    queryFn: fetchProducts,
    staleTime: 5 * 60 * 1000, // Fresh for 5 minutes
  });

  const prefetchProduct = (productId: number) => {
    queryClient.prefetchQuery({
      queryKey: ['product', productId],
      queryFn: () => fetchProduct(productId),
      staleTime: 10 * 60 * 1000, // Fresh for 10 minutes
    });
  };

  if (isLoading) {
    return <div>Loading products...</div>;
  }

  if (error) {
    return (
      <div>
        <p>Failed to load products: {error.message}</p>
        <button onClick={() => window.location.reload()}>Retry</button>
      </div>
    );
  }

  return (
    <div>
      {products.map(product => (
        <Link
          key={product.id}
          to={`/products/${product.id}`}
          onMouseEnter={() => prefetchProduct(product.id)}
        >
          <div>{product.name}</div>
          <div>${product.price}</div>
        </Link>
      ))}
    </div>
  );
}

// ═══════════════════════════════════════════════════════
// PRODUCT DETAIL
// ═══════════════════════════════════════════════════════

import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';

export function ProductDetail() {
  const { id } = useParams<{ id: string }>();
  const productId = parseInt(id!);
  const queryClient = useQueryClient();

  const { data: product, isLoading } = useQuery({
    queryKey: ['product', productId],
    queryFn: () => fetchProduct(productId),
    staleTime: 10 * 60 * 1000,
    placeholderData: () => {
      // Try to get from products list cache
      const products = queryClient.getQueryData(['products']) as Product[];
      return products?.find(p => p.id === productId);
    },
  });

  if (isLoading && !product) {
    return <div>Loading product...</div>;
  }

  if (!product) {
    return <div>Product not found</div>;
  }

  return (
    <div>
      <h1>{product.name}</h1>
      <p>${product.price}</p>
    </div>
  );
}

// ═══════════════════════════════════════════════════════
// ADD TO CART (MUTATION WITH OPTIMISTIC UPDATE)
// ═══════════════════════════════════════════════════════

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

interface CartItem {
  productId: number;
  quantity: number;
}

async function addToCart(productId: number): Promise<CartItem> {
  const res = await fetch('/api/cart', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ productId, quantity: 1 }),
  });
  if (!res.ok) throw new Error('Failed to add to cart');
  return res.json();
}

function AddToCartButton({ productId }: { productId: number }) {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: () => addToCart(productId),

    onMutate: async () => {
      // Cancel outgoing queries
      await queryClient.cancelQueries({ queryKey: ['cart'] });

      // Snapshot current cart
      const previousCart = queryClient.getQueryData(['cart']);

      // Optimistically update UI
      queryClient.setQueryData(['cart'], (old: CartItem[] = []) => {
        const existingItem = old.find(item => item.productId === productId);
        if (existingItem) {
          return old.map(item =>
            item.productId === productId
              ? { ...item, quantity: item.quantity + 1 }
              : item
          );
        }
        return [...old, { productId, quantity: 1 }];
      });

      return { previousCart };
    },

    onError: (err, variables, context) => {
      // Rollback on error
      queryClient.setQueryData(['cart'], context?.previousCart);
      alert('Failed to add to cart. Please try again.');
    },

    onSettled: () => {
      // Always refetch cart to sync with server
      queryClient.invalidateQueries({ queryKey: ['cart'] });
    },
  });

  return (
    <button
      onClick={() => mutation.mutate()}
      disabled={mutation.isPending}
      className="btn-primary"
    >
      {mutation.isPending ? 'Adding...' : 'Add to Cart'}
    </button>
  );
}
Billie Heidelberg Jr.

About Billie Heidelberg Jr.

Full Stack Developer & Technical Leader with 8+ years of experience building scalable applications and leading development teams. Passionate about sharing knowledge and helping others grow.

Want to Connect?

I'm always interested in discussing development challenges, trading technology, or potential collaboration opportunities.

Read more articles like this

← Back to all articles