Skip to content

Chapter 6: React Query - Caching & Revalidation

Theoretical Foundations

In traditional client-side data management, the data fetched from a server is tightly coupled with the component's lifecycle. When a component mounts, it fetches data; when it unmounts, the data is lost. This leads to a fragile system where every new component that needs the same data must re-fetch it, resulting in duplicate network requests, stale data, and a jarring user experience (e.g., loading spinners appearing every time a user navigates back to a page).

React Query fundamentally shifts this paradigm by treating server state as a distinct entity from UI state. While UI state (like a button's disabled state or a form's input value) is ephemeral and local, server state is asynchronous, potentially outdated, and shared across the entire application. React Query acts as an intelligent synchronization layer that sits between your application and your backend, managing the lifecycle of this server state independently.

The Stale-While-Revalidate Strategy

The cornerstone of React Query's efficiency is the stale-while-revalidate caching strategy. This is not a simple "cache-then-serve" mechanism; it is a sophisticated data freshness protocol.

  1. Stale Data is Still Good Data: When data is fetched, it is marked as "stale" immediately or after a configurable duration (e.g., 5 minutes). However, "stale" does not mean "incorrect." It simply means the data is potentially outdated and should be re-validated in the background.
  2. The User Experience: When a user requests data that is in the cache but marked as stale, React Query does two things simultaneously:
    • It immediately returns the stale data to the UI, allowing the component to render instantly without a loading spinner.
    • It triggers a background refetch to update the data silently.
    • Once the background fetch resolves, the UI seamlessly updates with the fresh data.

This creates a perception of zero-latency for the user, as they are never blocked by network requests, while ensuring data eventually converges to the most up-to-date version.

The "Why": The Cost of Naive Fetching

To understand the necessity of React Query, consider a standard e-commerce dashboard. You have a "Product List" page and a "Product Detail" page.

  • Scenario A (Naive Fetching): The user loads the list, clicks a product to view details, and then clicks "Back" to the list.
    • Action: The list component unmounts. When the user returns, the component mounts again and executes a network request.
    • Result: Unnecessary bandwidth usage, slower rendering, and potential data inconsistency if the product was updated in the interim.
  • Scenario B (React Query): The user loads the list, clicks a product, and returns.
    • Action: The list component mounts. React Query checks its cache. It finds the data (even if stale) and displays it instantly. In the background, it checks if the data needs revalidation.
    • Result: Instant navigation, reduced server load, and a snappy, app-like feel.

This is the Non-Blocking I/O paradigm applied to the frontend. Just as Node.js delegates heavy I/O operations to avoid blocking the main thread, React Query delegates data fetching to a background process, keeping the UI thread free to handle user interactions immediately.

The Under the Hood: The Query Lifecycle and The Observer Pattern

React Query is not a simple fetch wrapper; it is a state machine governed by the Observer Pattern. When you call useQuery, you are not just fetching data; you are creating an Observer that subscribes to a Query Cache.

The Lifecycle States

Every query in React Query exists in one of several states, visualized below. This state machine is what allows for granular control over the UI (e.g., showing different skeletons for loading vs. refetching).

A diagram contrasts loading and refetching skeletons, illustrating that the initial load displays a full-screen placeholder while refetching overlays a subtle, non-blocking shimmer on existing content.
Hold "Ctrl" to enable pan & zoom

A diagram contrasts loading and refetching skeletons, illustrating that the initial load displays a full-screen placeholder while refetching overlays a subtle, non-blocking shimmer on existing content.
  1. Idle: The query exists but hasn't been mounted or has been garbage collected.
  2. Pending: The query is executing its primary fetch function. This is where you typically show a loading spinner or skeleton.
  3. Success: The data has been successfully retrieved and is available in the cache. The UI renders the data.
  4. Fetching: The data is currently stale, and a background refetch is in progress. The UI continues to show the cached data (Success state) while the network request happens.
  5. Error: The fetch failed. The error object is stored in the cache.

The Observer Pattern & Garbage Collection

When a component uses useQuery, it subscribes to the query key (e.g., ['todos', 1]). If multiple components subscribe to the same key, they all share the same cache entry. When the last component unmounts, the query enters a "garbage collection" phase. It doesn't disappear immediately; it remains in the cache for a configurable time (default 5 minutes) to allow for instant remounting if the user navigates back.

Analogy: The Librarian vs. The Bookshelf

To visualize this, imagine a Librarian (React Query) managing a Bookshelf (Query Cache).

  • Traditional Fetching: Every time you want to read a book, you must walk to the library (network request), find the book, and read it. If you leave the room and come back, you have to walk back to the library again.
  • React Query: You ask the Librarian for a book.
    • The Librarian checks the Bookshelf.
    • If the book is there and "fresh" (not stale): Hand it to you immediately.
    • If the book is there but "stale" (dusty): The Librarian hands you the book immediately (so you can start reading), but simultaneously sends an assistant to the library to fetch the latest edition.
    • If the book is not there: The Librarian goes to the library, gets the book, and brings it back.

This analogy highlights the stale-while-revalidate strategy. You never wait for the assistant; you always have a book in hand, even if it's slightly old. The assistant ensures the bookshelf is eventually up to date.

Comparison: React Query vs. React useTransition

It is crucial to distinguish React Query from React's concurrent features. They solve different problems but can work together.

  • React useTransition is about UI Responsiveness. It allows you to mark state updates as "non-urgent," keeping the UI responsive during expensive renders or server-side computations. It is concerned with the rendering pipeline.
  • React Query is about Data Synchronization. It is concerned with the network pipeline and the data cache.

The Synergy: Imagine a user searching for a product.

  1. React Query fetches the search results from the API.
  2. While the data is being fetched, React useTransition ensures that the user can still type in the search bar or click other buttons without the UI freezing.
// Conceptual integration (No implementation details)
import { useQuery } from '@tanstack/react-query';
import { useTransition } from 'react';

function SearchComponent() {
  // React Query handles the data fetching logic and caching
  const { data, isLoading } = useQuery({
    queryKey: ['search', searchTerm],
    queryFn: () => fetchResults(searchTerm),
    enabled: !!searchTerm, // Only fetch if we have a term
  });

  // useTransition handles the UI update scheduling
  const [isPending, startTransition] = useTransition();

  const handleSearch = (term: string) => {
    startTransition(() => {
      setSearchTerm(term); // This update is deprioritized to keep UI snappy
    });
  };

  // The UI can react to both the data state (isLoading) 
  // and the render state (isPending) independently.
  return (
    <div>
      {isPending && <div>Updating UI...</div>}
      {isLoading && <div>Fetching Data...</div>}
      {/* Render results */}
    </div>
  );
}

The "Why" Revisited: Optimistic Updates and Conflict Resolution

The theoretical foundation of React Query extends into handling user mutations. When a user performs an action (e.g., liking a post), waiting for the server response creates latency.

Optimistic Updates leverage the cache to simulate success immediately:

  1. User Action: User clicks "Like".
  2. Immediate Cache Update: React Query updates the local cache instantly (e.g., incrementing the like count and changing the button color).
  3. Background Mutation: The mutation request is sent to the server.
  4. Revalidation: Upon success, the cache is confirmed. Upon failure, the cache is rolled back to the previous state, and an error is shown.

This pattern relies entirely on the stability of the Query Key and the Stale-While-Revalidate mechanism. Without a robust caching layer, optimistic updates would cause data divergence where the UI shows one state and the server holds another.

Theoretical Foundations

React Query is not merely a data fetcher; it is a state management library for server state. It abstracts away the complexity of caching, background revalidation, and synchronization, allowing developers to treat asynchronous data as a first-class citizen in their application state. By decoupling server state from UI state, it enables the creation of complex, high-performance applications that feel instantaneous to the user, even over slow networks.

Basic Code Example

In a modern SaaS application, you manage two distinct types of state:

  1. UI State: Local to the component (e.g., is a modal open? what is the value of a search input?). This is handled by useState.
  2. Server State: Data that lives on the server and is shared across users (e.g., a list of invoices, user profile data). This is asynchronous, prone to errors, and needs caching.

React Query (TanStack Query) manages Server State. It acts as a bridge between your UI and your backend API. Its default strategy is "Stale-While-Revalidate":

  1. Serve from Cache: Instantly show the data you have (even if it's slightly old).
  2. Revalidate in Background: While the user sees the data, React Query silently fetches fresh data in the background.
  3. Update UI: Once fresh data arrives, the UI updates seamlessly.

Visualizing the React Query Lifecycle

This diagram illustrates the flow of data during a standard fetch operation. Notice how the UI is never blocked waiting for the network.

This diagram visualizes the non-blocking React Query lifecycle, where the UI renders an immediate fallback state while the network request processes asynchronously in the background, seamlessly updating the view once data arrives.
Hold "Ctrl" to enable pan & zoom

This diagram visualizes the non-blocking React Query lifecycle, where the UI renders an immediate fallback state while the network request processes asynchronously in the background, seamlessly updating the view once data arrives.

"Hello World" Code Example: Invoice Dashboard

In this SaaS context, we will build a simple dashboard that fetches a list of invoices. We will simulate a backend API that might be slow or return errors.

Prerequisites: npm install @tanstack/react-query react react-dom

// File: InvoiceDashboard.tsx
import React from 'react';
import { 
  useQuery, 
  useQueryClient, 
  QueryClient, 
  QueryClientProvider 
} from '@tanstack/react-query';

// ==========================================
// 1. MOCK BACKEND API (Simulating a SaaS Backend)
// ==========================================

/**

 * Simulates a network request to fetch invoices.
 * In a real app, this would be `fetch('/api/invoices')`.
 */
const fetchInvoices = async (): Promise<Array<{ id: string; amount: number; client: string }>> => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      // Simulate 50% chance of random error to demonstrate error handling
      if (Math.random() > 0.5) {
        reject(new Error('Failed to connect to database.'));
        return;
      }

      resolve([
        { id: 'INV-001', amount: 1200, client: 'Acme Corp' },
        { id: 'INV-002', amount: 450, client: 'Globex Inc' },
        { id: 'INV-003', amount: 3200, client: 'Soylent Corp' },
      ]);
    }, 1500); // Simulate network latency
  });
};

// ==========================================
// 2. REACT COMPONENT: Invoice List
// ==========================================

/**

 * The UI Component.
 * It does NOT manage loading states or error states manually.
 * It delegates that logic to React Query.
 */
const InvoiceList = () => {
  // useQuery is the hook that orchestrates the data fetching lifecycle.
  const { 
    data,      // The actual data (undefined until fetched)
    error,     // Error object (if the fetch fails)
    isLoading, // Boolean: true only on initial fetch (no cache)
    isFetching // Boolean: true whenever fetching (including background)
  } = useQuery({
    queryKey: ['invoices'], // Unique ID for this data cache
    queryFn: fetchInvoices, // The function that returns the data
    staleTime: 5000,        // Data is fresh for 5 seconds (no background refetch)
  });

  // 1. LOADING STATE (Initial fetch only)
  if (isLoading) {
    return <div className="p-4 text-blue-600">Loading invoices...</div>;
  }

  // 2. ERROR STATE
  if (error) {
    return (
      <div className="p-4 text-red-600">
        Error: {(error as Error).message}
        <button 
          onClick={() => window.location.reload()} 
          className="ml-4 underline"
        >
          Retry
        </button>
      </div>
    );
  }

  // 3. SUCCESS STATE
  return (
    <div className="p-4 border rounded shadow-sm">
      <h2 className="text-xl font-bold mb-2">Invoice Dashboard</h2>

      {/* Visual indicator that a background fetch is happening */}
      {isFetching && <small className="text-gray-500">Updating...</small>}

      <ul className="mt-2 space-y-2">
        {data?.map((invoice) => (
          <li key={invoice.id} className="flex justify-between border-b pb-1">
            <span>{invoice.client}</span>
            <span className="font-mono">${invoice.amount}</span>
          </li>
        ))}
      </ul>
    </div>
  );
};

// ==========================================
// 3. APP SETUP: Providers
// ==========================================

/**

 * In a real app, this QueryClient setup is usually in `main.tsx` or `App.tsx`.
 * We configure global defaults here.
 */
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 2, // Retry failed fetches 2 times before failing
      refetchOnWindowFocus: true, // Refetch when user tabs back to window
    }
  }
});

export const InvoiceDashboardApp = () => {
  return (
    // The Provider makes the cache available to all child components
    <QueryClientProvider client={queryClient}>
      <div className="max-w-md mx-auto mt-10">
        <h1 className="text-2xl font-bold mb-4 text-center">SaaS Invoice App</h1>
        <InvoiceList />

        {/* Optional: React Query DevTools (only in dev) */}
        {/* <ReactQueryDevtools /> */}
      </div>
    </QueryClientProvider>
  );
};

Detailed Line-by-Line Explanation

1. The Mock Backend (fetchInvoices)

const fetchInvoices = async (): Promise<Array<{ id: string; ... }>> => { ... }
  • Why: React Query expects a function that returns a Promise. This function is where you place your actual fetch or axios call.
  • Simulated Latency: setTimeout(..., 1500) mimics a slow database query. This allows you to see the "Loading" state in the UI.
  • Simulated Error: Math.random() > 0.5 forces a failure 50% of the time. This is crucial for testing how your UI handles API instability without crashing the app.

2. The useQuery Hook

const { data, error, isLoading, isFetching } = useQuery({
  queryKey: ['invoices'],
  queryFn: fetchInvoices,
  staleTime: 5000,
});
This is the heart of the library.

  • queryKey: ['invoices']: This is the unique identifier for this piece of server state. It acts as a key in the global cache. If you change this key (e.g., ['invoices', userId]), React Query fetches new data.
  • queryFn: fetchInvoices: The actual logic to get the data.
  • staleTime: 5000: By default, data is considered "stale" immediately. Setting this to 5000ms means that for 5 seconds after a successful fetch, the data is considered "fresh."
    • Fresh: No background refetching occurs.
    • Stale: Data is still displayed, but a background refetch is triggered (e.g., when the component remounts or the window regains focus).
  • Return Values:
    • data: The resolved data from the promise.
    • isLoading: Only true when there is no data in the cache and the query is running. It is false during background refetches.
    • isFetching: true whenever the network request is in flight (initial load or background refetch). We use this to show a subtle "Updating..." indicator.

3. The Component Logic

if (isLoading) { ... }
if (error) { ... }
  • Declarative Rendering: We check states explicitly. React Query handles the complex state machine (pending -> success -> error -> refetching) so we don't need complex useEffect chains.
  • Error Boundaries: Notice we don't just throw the error. We catch it and render a user-friendly message with a retry button.

4. The Provider

<QueryClientProvider client={queryClient}>
  • Context: This component creates a React Context that holds the QueryClient instance. Any component inside this tree can access the cache.
  • Configuration: We passed retry: 2 to the client. If fetchInvoices fails, React Query will automatically retry it twice before finally setting the error state. This handles transient network glitches (e.g., a dropped packet) silently.

Common Pitfalls in React Query (JS/TS)

1. The "Stale-While-Revalidate" Misunderstanding

The Issue: Developers often set staleTime: Infinity to "cache forever" or leave it at 0 (default) and wonder why they see flickering or unnecessary network requests. The Reality:

  • Default (0ms): Data is immediately stale. As soon as you switch tabs and come back, or the component remounts, React Query will refetch in the background. This is good for data that changes often (stock prices), but bad for static data (blog posts).
  • Solution: Set staleTime based on data volatility. For a user profile (rarely changes), staleTime: 5 * 60 * 1000 (5 minutes) is reasonable.

2. Async/Await in useEffect vs. useQuery

The Issue: Beginners often try to fetch data manually inside useEffect and store it in local useState.

// ❌ BAD: Manual state management
const [data, setData] = useState(null);
useEffect(() => {
  fetch('/api/data').then(res => setData(res));
}, []);
Why it fails: You lose caching, loading states, error handling, and revalidation. If the user navigates away and comes back, you fetch again. The Solution: Always use useQuery for fetching data. Use useMutation for updating data.

3. Type Safety with unknown

The Issue: The data returned by useQuery is typed as unknown until you provide a generic or a runtime validator.

// ⚠️ RISKY: TypeScript assumes 'data' is unknown
console.log(data.amount); // Error: Property 'amount' does not exist on type 'unknown'
The Solution: Use a library like Zod or Yup to validate the API response at runtime, or cast the type explicitly (though validation is safer).
// ✅ SAFE: Using Zod for runtime validation
import { z } from 'zod';

const InvoiceSchema = z.object({ id: z.string(), amount: z.number() });

const { data } = useQuery({
  queryKey: ['invoices'],
  queryFn: fetchInvoices,
  select: (rawData) => rawData.map(item => InvoiceSchema.parse(item)) // Validates data
});

4. Vercel/Edge Function Timeouts

The Issue: When using Edge Functions (like Vercel), there is a strict timeout (usually 10s for Hobby plans). If your React Query staleTime is too short and your database query is slow, you might hit the timeout during a background refetch. The Solution:

  1. Optimize DB: Ensure your database indexes are correct.
  2. Increase staleTime: Reduce the frequency of fetches.
  3. Handle 408/504: Ensure your API returns a specific status code for timeouts so React Query knows not to retry indefinitely (though retry is limited by default).

5. Infinite Loops with refetchInterval

The Issue: Enabling polling without proper guards can crash the browser or hit API rate limits.

// ⚠️ DANGER: If the fetch fails, it keeps retrying immediately
useQuery({
  queryKey: ['data'],
  queryFn: fetchData,
  refetchInterval: 2000, // Fetch every 2 seconds
})
The Solution: Combine with refetchIntervalInBackground or ensure your API handles rate limiting. If the data is critical, use WebSockets instead of polling.

The chapter continues with advanced code, exercises and solutions with analysis, you can find them on the ebook on Leanpub.com or Amazon


Loading knowledge check...



Code License: All code examples are released under the MIT License. Github repo.

Content Copyright: Copyright © 2026 Edgar Milvus | Privacy & Cookie Policy. All rights reserved.

All textual explanations, original diagrams, and illustrations are the intellectual property of the author. To support the maintenance of this site via AdSense, please read this content exclusively online. Copying, redistribution, or reproduction is strictly prohibited.