Skip to main content
  1. Frontends/
  2. React Guides/

Mastering TanStack Query v5: Enterprise-Grade Async State Management

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

If you are still managing your API data with a combination of useEffect, useState, and a messy boolean flag for isLoading, it is time to stop.

By 2025, the React ecosystem has firmly established that server state is not the same as client state. They have different lifecycles, different persistence requirements, and require different mental models. Client state is synchronous and owned by the browser session. Server state is asynchronous, owned by a remote database, and potentially outdated the moment it hits your client.

TanStack Query (formerly React Query) v5 is the definitive solution to this problem. It doesn’t just “fetch data”; it acts as an intelligent synchronization engine between your user’s screen and your backend.

In this deep dive, we aren’t just going to look at the docs. We are going to architect a robust data layer using TanStack Query v5, covering caching strategies, optimistic updates (the holy grail of UX), and avoiding the common pitfalls that trip up even senior developers.

Prerequisites and Environment
#

To follow along, you should have a solid grasp of React hooks and TypeScript. We assume you are working in a modern 2025 environment:

  • Node.js: v20+ (LTS)
  • React: v18.3 or v19
  • TypeScript: v5.x
  • Package Manager: npm, pnpm, or bun

We will set up a streamlined environment using Vite.

# Initialize a new TypeScript React project
npm create vite@latest tanstack-mastery -- --template react-ts

# Navigate and install dependencies
cd tanstack-mastery
npm install @tanstack/react-query @tanstack/react-query-devtools axios
npm install

The Mental Model: Stale-While-Revalidate
#

Before writing code, you must understand the core philosophy of TanStack Query. It uses a caching strategy known as Stale-While-Revalidate.

The library assumes that all data you fetch is immediately “stale” (old) unless you tell it otherwise. It shows the user the cached data instantly (if available) while simultaneously fetching fresh data in the background to update the UI.

Here is the flow of a standard query lifecycle in v5:

flowchart TD A[Component Mounts] --> B{Data in Cache?} B -- No --> C[Hard Loading State] C --> D[Fetch API] B -- Yes --> E{Is Data Stale?} E -- No --> F[Show Cached Data] E -- Yes --> G[Show Cached Data + Background Refetch] G --> D D --> H[Update Cache] H --> I[Rerender Component] style A fill:#f9f,stroke:#333,stroke-width:2px style D fill:#bbf,stroke:#333,stroke-width:2px style I fill:#bfb,stroke:#333,stroke-width:2px

Step 1: Global Configuration
#

The mistake most developers make is using the default configuration without understanding the implications. The defaults are aggressive: they refetch on window focus, on network reconnect, and on mount if the data is stale.

Let’s set up a QueryClient with “Safe for Production” defaults.

Create a file src/lib/queryClient.ts:

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

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // Data is considered fresh for 1 minute.
      // No background refetches will happen during this time.
      staleTime: 1000 * 60, 
      
      // If a query fails, retry 1 time before throwing an error.
      retry: 1,
      
      // Do not refetch just because the user clicked back to the window
      // unless the data is actually stale.
      refetchOnWindowFocus: false, 
    },
  },
});

Now, wrap your application in src/main.tsx:

import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { queryClient } from './lib/queryClient';
import App from './App';
import './index.css';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
      {/* The Devtools are essential for debugging cache states */}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  </React.StrictMode>,
);

Step 2: Typed Queries and Custom Hooks
#

In v5, the useQuery signature changed. You now pass a single object instead of separate arguments. This improves type safety and readability.

We will simulate a robust API layer. Don’t call axios directly in your components; abstract it.

The API Layer (src/api/todos.ts)
#

import axios from 'axios';

// Define the shape of our data
export interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

const api = axios.create({
  baseURL: 'https://jsonplaceholder.typicode.com',
});

export const fetchTodos = async (): Promise<Todo[]> => {
  const { data } = await api.get<Todo[]>('/todos?_limit=10');
  return data;
};

export const updateTodo = async (todo: Todo): Promise<Todo> => {
  const { data } = await api.put<Todo>(`/todos/${todo.id}`, todo);
  return data;
};

The Custom Hook (src/hooks/useTodos.ts)
#

Encapsulate your query logic. This allows you to reuse the data fetching logic across multiple components without duplicating the query key or the fetch function.

import { useQuery } from '@tanstack/react-query';
import { fetchTodos, Todo } from '../api/todos';

// Centralize keys to avoid typos and ensure cache invalidation works
export const todoKeys = {
  all: ['todos'] as const,
  lists: () => [...todoKeys.all, 'list'] as const,
  detail: (id: number) => [...todoKeys.all, 'detail', id] as const,
};

export const useTodos = () => {
  return useQuery({
    queryKey: todoKeys.lists(),
    queryFn: fetchTodos,
    // Transformations: Select only what the UI needs
    select: (data) => data.sort((a, b) => b.id - a.id), 
  });
};

Step 3: Implementation and State States
#

Now, let’s build the component. Notice how we handle the status string instead of separate booleans. It’s cleaner and covers all mathematical possibilities of the state machine.

// src/components/TodoList.tsx
import { useTodos } from '../hooks/useTodos';

export const TodoList = () => {
  const { data, status, error, isFetching } = useTodos();

  if (status === 'pending') {
    return <div className="p-4 text-center">Loading your tasks...</div>;
  }

  if (status === 'error') {
    return (
      <div className="p-4 text-red-500 bg-red-50 rounded">
        Error: {error.message}
      </div>
    );
  }

  return (
    <div className="max-w-md mx-auto mt-10 p-6 bg-white rounded-xl shadow-md">
      <h2 className="text-2xl font-bold mb-4 flex justify-between items-center">
        Todo List
        {/* Visual indicator for background refetches */}
        {isFetching && <span className="text-xs text-blue-500">Updating...</span>}
      </h2>
      <ul className="space-y-3">
        {data.map((todo) => (
          <li 
            key={todo.id} 
            className="flex items-center p-3 border rounded hover:bg-gray-50 transition"
          >
            <span className={todo.completed ? 'line-through text-gray-400' : ''}>
              {todo.title}
            </span>
          </li>
        ))}
      </ul>
    </div>
  );
};

Step 4: Mastering Mutations & Optimistic Updates
#

This is where v5 shines. An Optimistic Update updates the UI before the server responds. If the server request fails, we roll back the UI. This makes your app feel instant.

In TanStack Query v5, we use useMutation combined with onMutate.

Create a new hook in src/hooks/useMutateTodo.ts:

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { updateTodo, Todo } from '../api/todos';
import { todoKeys } from './useTodos';

export const useToggleTodo = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: updateTodo,
    
    // When mutate is called:
    onMutate: async (newTodo) => {
      // 1. Cancel any outgoing refetches (so they don't overwrite our optimistic update)
      await queryClient.cancelQueries({ queryKey: todoKeys.lists() });

      // 2. Snapshot the previous value
      const previousTodos = queryClient.getQueryData<Todo[]>(todoKeys.lists());

      // 3. Optimistically update the cache
      if (previousTodos) {
        queryClient.setQueryData<Todo[]>(todoKeys.lists(), (old) => {
          return old?.map((t) => 
            t.id === newTodo.id ? { ...t, ...newTodo } : t
          );
        });
      }

      // 4. Return a context object with the snapshotted value
      return { previousTodos };
    },

    // If the mutation fails, use the context returned from onMutate to roll back
    onError: (_err, _newTodo, context) => {
      if (context?.previousTodos) {
        queryClient.setQueryData(todoKeys.lists(), context.previousTodos);
      }
    },

    // Always refetch after error or success to ensure we are in sync with server
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
    },
  });
};

Integrating the Mutation
#

Update your TodoList.tsx to use this hook:

// Inside TodoList component
import { useToggleTodo } from '../hooks/useMutateTodo';

// ... inside the component
const { mutate } = useToggleTodo();

// ... inside the .map render
<li 
  key={todo.id} 
  onClick={() => mutate({ ...todo, completed: !todo.completed })}
  className="cursor-pointer flex items-center..." // styles truncated
>
  {/* ... content */}
</li>

Comparison: Managing State Strategies
#

To understand why we went through this setup, let’s compare the approaches available in 2025.

Feature useEffect + useState Redux Toolkit Query TanStack Query v5
Boilerplate High (manual loading/error states) Medium (slices, store config) Low (Hooks only)
Caching Manual (hard to implement) Built-in Advanced (gcTime, staleTime, persisters)
Deduping No Yes Yes (Automatic request coalescing)
DevTools None (console.log) Redux DevTools Dedicated Query DevTools
Bundle Size 0kb (native) ~15kb (with Redux) ~13kb (Tree-shakable)
Architecture Imperative centralized Declarative / Decentralized

Best Practices and Common Pitfalls
#

1. staleTime vs gcTime (formerly cacheTime)
#

This causes the most confusion.

  • staleTime: How long data is considered “fresh”. While fresh, data is pulled from cache without a network request. Default is 0.
  • gcTime: How long inactive data remains in memory before the garbage collector deletes it. Default is 5 minutes.

Pro Tip: If your data doesn’t change often (like a user profile), set staleTime to Infinity. If it changes frequently (like a stock ticker), keep it at 0.

2. The useQuery Callback Trap
#

In v4, you could pass onSuccess or onError callbacks to useQuery. These were removed in v5. Why? Because useQuery runs on every render. Having side effects (like triggering a toast notification) inside a query definition led to unpredictable behavior.

Solution: Handle side effects in useEffect referencing data or error, or handle errors at the UI boundary level (Error Boundaries).

3. Query Key Factories
#

Never hardcode strings like ['todos', id] in your components. As shown in Step 2, use a “Query Key Factory” object. This makes refactoring easier and prevents cache invalidation bugs where you invalidate ['todo'] but your component is listening to ['todos'].

4. Dependent Queries
#

Sometimes you can’t fetch B until you have A. TanStack Query handles this gracefully using the enabled property.

const { data: user } = useUser();
const { data: projects } = useQuery({
  queryKey: ['projects', user?.id],
  queryFn: () => fetchProjects(user.id),
  // The query will not run until user.id exists
  enabled: !!user?.id, 
});

Conclusion
#

TanStack Query v5 has effectively solved the server state problem in React. By adopting it, you remove hundreds of lines of fragile useEffect code and replace it with a battle-tested, declarative API.

We have covered:

  1. Architecture: Separating Client vs. Server state.
  2. Configuration: Setting safe defaults for production.
  3. Typing: Leveraging strict TypeScript for safety.
  4. UX: Implementing Optimistic Updates for zero-latency interactions.

Moving forward, consider exploring Infinite Queries for feed-based interfaces or Suspense integration if you are moving towards a fully Suspense-enabled architecture in React 19.

Further Reading
#

Disclaimer: This code is designed for React 18/19 environments. Always ensure your backend API handles concurrency correctly when implementing optimistic updates.