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,isMountedflags, 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,
useStateor a lightweight store withZustandis 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
staleTimebetween 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
useQuerycalls directly across view components. Wrap them in dedicated custom hooks likeuseTodos()oruseCreateTodo()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.

