Mastering TanStack Query v5 in React: Practical Server State Management, Caching, and Optimistic Updates

Development tutorial - IT technology blog
Development tutorial - IT technology blog

The Pain of Using useEffect for API Calls

Before switching to TanStack Query (formerly React Query), our team’s data fetching workflow revolved around the familiar trio: useState for data, isLoading, and error, wrapping the API call inside useEffect. When our project had only a few basic CRUD views, this approach worked reasonably well. But as our application grew past 40 screens, major issues quickly surfaced:

  • Duplicate requests: Two widgets in the Header and Sidebar both needed the notification list. As a result, the browser fired two identical requests to the backend within 100ms.
  • Out-of-sync data (Stale data): A user updated their display name on the Profile page, but when navigating back to the Dashboard, the old name persisted. The reason was simple: the Dashboard component hadn’t unmounted, so the state didn’t re-trigger a fresh fetch.
  • Bloated boilerplate code: Repeatedly writing AbortController, isMounted flags, retry logic, and try/catch blocks forced a 50-line UI component to carry an extra 100 lines of network-handling code.

Many teams solve this by dumping all API responses into Redux Toolkit or Zustand. After running TanStack Query v5 in production for over six months, I realized that doing so only makes life harder. Client UI state and server-side data are fundamentally different; conflating them only clutters the codebase.

3 Core Concepts You Need to Master

Rather than memorizing dozens of configuration options from the docs, you only need to grasp these three foundational mental models.

1. The Boundary Between Client State and Server State

Misidentifying your state type will derail your app’s architecture from day one:

  • Client State: Pure UI data that is fully synchronous and lives entirely within the browser’s memory. Examples include modal visibility, the active tab, or dark mode toggles. For these, useState or a lightweight store with Zustand is more than enough.
  • Server State: Data that actually belongs to the backend database. It is asynchronous and can be mutated by other users at any time without the client knowing. This category requires caching, revalidation, and automated retry mechanisms. This is TanStack Query’s sweet spot.

2. How Does staleTime Differ From gcTime?

This is the most common point of confusion, frequently leading to API spamming or memory leaks:

  • staleTime (Default: 0): The duration during which data is considered “fresh.” As long as data remains fresh, TanStack Query serves it directly from memory without contacting the server. With the default staleTime: 0, data is immediately treated as stale right after being fetched. Every time a user switches tabs and returns, the app silently triggers a background refetch to sync up.
  • gcTime (Garbage Collection Time – renamed from cacheTime in v5, Default: 5 minutes): The duration unused (inactive) data is retained in memory. When no mounted components are subscribed to a query key, the gcTime countdown begins. Once it expires, the garbage collector wipes it clean to free up RAM.

3. Standardizing Query Keys with Hierarchy

Starting in v5, Query Keys must strictly be an Array. The library uses deep array hashing to identify cached data. Organize keys into factory objects structured from generic to specific:

// Query Key Factory: clean and easy to invalidate in bulk
export const todoKeys = {
  all: ['todos'] as const,
  lists: () => [...todoKeys.all, 'list'] as const,
  list: (filters: { status?: string }) => [...todoKeys.lists(), filters] as const,
  details: () => [...todoKeys.all, 'detail'] as const,
  detail: (id: string) => [...todoKeys.details(), id] as const,
};

Step-by-Step Practical Guide to TanStack Query v5

We will walk through setting up the foundation, fetching data using v5 syntax, and implementing Optimistic Updates.

Step 1: Install and Configure QueryClient Correctly

Install the core package along with the DevTools:

npm install @tanstack/react-query @tanstack/react-query-devtools

In your root file (such as App.tsx or main.tsx), instantiate QueryClient. Avoid leaving staleTime at 0 unless you want to overwhelm your backend:

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

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 2, // Data stays "fresh" for 2 minutes
      gcTime: 1000 * 60 * 10,    // Keep idle cache in RAM for up to 10 minutes
      retry: 1,                 // Retry once on API failure before reporting an error
      refetchOnWindowFocus: false, // Disable automatic refetch when the user focuses the tab
    },
  },
});

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

Step 2: Fetching Data with useQuery

TanStack Query v5 completely dropped positional arguments. The hook now accepts only a single configuration object. Additionally, isPending replaces isLoading when the query has no cached data yet:

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

interface Todo {
  id: string;
  title: string;
  completed: boolean;
}

const fetchTodos = async (): Promise<Todo[]> => {
  const res = await fetch('/api/todos');
  if (!res.ok) throw new Error('Failed to fetch todo list');
  return res.json();
};

export function TodoList() {
  const { data: todos, isPending, isError, error } = useQuery({
    queryKey: todoKeys.lists(),
    queryFn: fetchTodos,
  });

  if (isPending) return <p>Syncing data...</p>;
  if (isError) return <p className="error">Error: {error.message}</p>;

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>
          <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
            {todo.title}
          </span>
        </li>
      ))}
    </ul>
  );
}

Step 3: Mutating Data and Auto-Refetching with useMutation

When you need to make POST, PUT, or DELETE requests, use useMutation. Once a mutation completes, calling invalidateQueries automatically prompts TanStack Query to refetch fresh data across active components.

In a recent e-commerce project of ours, adopting this pattern eliminated tedious prop drilling of callbacks from parent to child components and cleaned up dozens of verbose Redux actions:

import React, { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { todoKeys } from './queryKeys';

const createTodo = async (title: string) => {
  const res = await fetch('/api/todos', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title, completed: false }),
  });
  if (!res.ok) throw new Error('Failed to create todo');
  return res.json();
};

export function AddTodo() {
  const [title, setTitle] = useState('');
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: createTodo,
    onSuccess: () => {
      // Invalidate todo list cache -> immediately triggers background refetch
      queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
      setTitle('');
    },
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (title.trim()) mutation.mutate(title.trim());
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Add a new task..."
      />
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? 'Saving...' : 'Add'}
      </button>
    </form>
  );
}

Step 4: Seamless, Zero-Latency UI with Optimistic Updates

On unstable networks with 400ms latency, waiting half a second for a checkbox to toggle after clicking is a jarring user experience.

Optimistic Updates resolve this problem entirely. The interface transitions state immediately upon clicking. The server processes the mutation in the background, and if an unexpected 500 error occurs, the UI seamlessly rolls back to its previous state:

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

interface TogglePayload {
  id: string;
  completed: boolean;
}

const patchTodo = async ({ id, completed }: TogglePayload) => {
  const res = await fetch(`/api/todos/${id}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ completed }),
  });
  if (!res.ok) throw new Error('Failed to update status');
  return res.json();
};

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

  return useMutation({
    mutationFn: patchTodo,
    onMutate: async (updatedTodo) => {
      // Cancel outgoing queries to avoid race conditions
      await queryClient.cancelQueries({ queryKey: todoKeys.lists() });

      // Snapshot previous cache for rollback on error
      const previousTodos = queryClient.getQueryData<Todo[]>(todoKeys.lists());

      // Optimistically update cache with new data
      queryClient.setQueryData<Todo[]>(todoKeys.lists(), (old = []) =>
        old.map((todo) =>
          todo.id === updatedTodo.id ? { ...todo, completed: updatedTodo.completed } : todo
        )
      );

      // Return context with the snapshotted value
      return { previousTodos };
    },
    onError: (_err, _variables, context) => {
      // Roll back to the previous snapshot if the API fails
      if (context?.previousTodos) {
        queryClient.setQueryData(todoKeys.lists(), context.previousTodos);
      }
    },
    onSettled: () => {
      // Always refetch to sync with the backend, regardless of success or error
      queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
    },
  });
}

Key Takeaways from Production

After refactoring our entire architecture to TanStack Query v5, our team reduced state management code by around 60%. Inconsistent data states between screens completely vanished. Here are three practical tips to keep in mind:

  • Avoid leaving staleTime at 0: For rarely changing data such as product catalogs or profile details, set staleTime between 1 and 5 minutes. Your backend engineers will thank you for sparing them thousands of redundant requests daily.
  • Enable DevTools in development: This visual inspector lets you see at a glance whether keys are Fresh, Stale, or Inactive, while tracking exactly which components are re-rendering.
  • Always encapsulate queries into Custom Hooks: Don’t scatter useQuery calls directly across view components. Wrap them in dedicated custom hooks like useTodos() or useCreateTodo() for cleaner, more reusable logic.

If you find yourself tangled in a web of messy useEffect calls or fatigued from dispatching Redux actions just to render a spinner, give TanStack Query v5 a try today. You will find managing server state to be significantly smoother.

Share: