Fix: TanStack Query Not Working — Data Not Fetching, Cache Not Updating, or Mutation Not Triggering Re-render
Part of: React & Frontend Errors
Quick Answer
How to fix TanStack Query (React Query v5) issues — query keys, stale time, enabled flag, mutation callbacks, optimistic updates, QueryClient setup, and SSR with prefetchQuery.
The Problem
A query never fires — the component stays in loading state forever:
const { data, isLoading } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
// isLoading is true indefinitely — queryFn never runsOr a mutation succeeds but the list doesn’t update:
const mutation = useMutation({
mutationFn: createUser,
onSuccess: () => {
// User created, but the list still shows old data
},
});Or data goes stale immediately and refetches on every focus:
const { data } = useQuery({
queryKey: ['config'],
queryFn: fetchConfig,
// Refetches every time the window regains focus — causing flicker
});Or nested query keys aren’t matching for cache invalidation:
// After creating a user, invalidating the cache doesn't refresh the list
queryClient.invalidateQueries({ queryKey: ['users'] });
// But the query was defined with queryKey: ['users', { role: 'admin' }]Why This Happens
TanStack Query is a cache, not a fetcher. Once you internalize that, most of the confusing behavior becomes predictable. The library identifies cached data by a queryKey — an array compared structurally — and routes every useQuery call to whatever entry matches. If two components share a key, they share data. If two components compute their keys slightly differently (an extra wrapper object, a different order of properties), they have separate cache entries and refetch independently. Half of all “data not updating” reports come from key drift between the read site and the invalidation site.
The second design point is that everything is async-stale by default. staleTime: 0 means data is stale the instant it arrives, so any remount, window focus, or reconnection triggers a background refetch. That refetch is silent — there is no loading spinner because the cached data is still served — but the queryFn does run. Users see flicker when the new response differs from the old. Setting a longer staleTime per query is the fix, not a global hack to disable refetch.
The third design point is that mutations do not know about queries. When you useMutation to create a user, the library has no idea your ['users'] query needs to refresh. You either invalidate (queryClient.invalidateQueries) to mark queries stale and trigger a background refetch, or you update the cache directly with setQueryData. Forgetting both is the most common “mutation succeeds but list does not update” bug.
queryFnis required — ifqueryFnis undefined or theenabledflag isfalse, the query never fires. No error is thrown — it just stays in the pending state.QueryClientmust be provided at the root — every component that callsuseQuerymust have aQueryClientProviderancestor. Missing the provider throws a runtime error.- Query keys must be serializable and stable — query keys are compared by deep equality. Objects or arrays that change reference on every render cause infinite refetch loops.
- Mutations don’t automatically update the cache — after a mutation, you must either invalidate affected queries or manually update the cache with
setQueryData. TanStack Query doesn’t know which queries a mutation affects. - Stale time defaults to 0 — by default, all data is considered stale immediately. Any window focus, reconnection, or component remount triggers a background refetch.
Diagnostic Timeline
The first instinct is “set staleTime higher” — that hides symptoms but rarely fixes the actual bug. Walk top-down through the cache.
Minute 0 — install DevTools. Stop guessing. Install @tanstack/react-query-devtools and mount it. Every query, its key, its data, its last fetch time, and its current state become visible. About 70% of “not working” reports resolve here — you discover the query is firing but with a different key than you expected, or it is in idle state because enabled: false, or error rather than success.
Minute 3 — check the queryKey. Open DevTools, find your query, and compare its key against your invalidation call. ['users'] and ['users', undefined] are different keys. ['users', { role: 'admin' }] and queryClient.invalidateQueries({ queryKey: ['users'] }) works because invalidation is a prefix match — but invalidateQueries({ queryKey: ['users'], exact: true }) does not. If exact match was on, this is your bug.
Minute 6 — verify queryFn reference stability. If a query refetches on every render, the queryKey is changing. Common culprit: an object literal inside the key like ['users', { filters }] where filters is constructed each render. Wrap it in useMemo or move the object construction outside the component. Same root cause if the queryFn captures a new closure each render — extract it to a stable reference.
Minute 10 — check the provider tree. “No QueryClient set” thrown deep in the tree usually means a child route was lazy-loaded and rendered before its provider mounted. Make sure QueryClientProvider wraps everything that calls useQuery, including suspense fallbacks and error boundaries. With Next.js App Router, use 'use client' on the provider component and pass it children — server components below cannot use useQuery directly.
Minute 14 — confirm the queryFn returns a stable shape. If the query refetches successfully but your component does not re-render, the queryFn may be returning a structurally equal but referentially new object that React treats as identical. Conversely, if the queryFn throws or returns undefined, the cache entry stays in error or pending forever and data is always undefined. Log inside queryFn to confirm it actually returns data.
Fix 1: Set Up QueryClient Correctly
// src/main.tsx (or app entry point)
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes — data stays fresh
gcTime: 1000 * 60 * 10, // 10 minutes — keep in cache after unmount
retry: 2, // Retry failed queries twice
refetchOnWindowFocus: false, // Disable refetch on tab focus (optional)
refetchOnReconnect: true,
},
mutations: {
retry: 0, // Don't retry failed mutations by default
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router />
{/* DevTools — shows in development only */}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}Verify setup with React Query DevTools — the DevTools panel shows all active queries, their state, data, and when they last fetched. Install with npm install @tanstack/react-query-devtools.
Fix 2: Write Queries Correctly
import { useQuery, keepPreviousData } from '@tanstack/react-query';
interface User {
id: number;
name: string;
email: string;
}
// Basic query
function UserList() {
const {
data,
isLoading, // True on first load (no cached data)
isFetching, // True whenever a request is in flight (incl. background)
isError,
error,
refetch,
} = useQuery({
queryKey: ['users'],
queryFn: async (): Promise<User[]> => {
const res = await fetch('/api/users');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
staleTime: 1000 * 60 * 5, // 5 min — override global default
});
if (isLoading) return <Spinner />;
if (isError) return <Error message={error.message} />;
return <ul>{data.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
// Query with parameters — include ALL variables in the key
function UserProfile({ userId }: { userId: number }) {
const { data: user } = useQuery({
queryKey: ['users', userId], // Key includes userId
queryFn: () => fetchUser(userId),
enabled: !!userId, // Only fetch when userId is defined
});
return <div>{user?.name}</div>;
}
// Paginated query
function PaginatedUsers() {
const [page, setPage] = useState(1);
const { data, isPlaceholderData } = useQuery({
queryKey: ['users', { page }], // Page is part of the key
queryFn: () => fetchUsers({ page }),
placeholderData: keepPreviousData, // Show old data while fetching new page
});
return (
<>
{data?.users.map(u => <li key={u.id}>{u.name}</li>)}
<button
onClick={() => setPage(p => p + 1)}
disabled={isPlaceholderData || !data?.hasMore}
>
Next
</button>
</>
);
}Query key best practices:
// Consistent key structure — use arrays
const queryKeys = {
all: ['users'] as const,
lists: () => [...queryKeys.all, 'list'] as const,
list: (filters: Filters) => [...queryKeys.lists(), filters] as const,
details: () => [...queryKeys.all, 'detail'] as const,
detail: (id: number) => [...queryKeys.details(), id] as const,
};
// Usage
useQuery({ queryKey: queryKeys.detail(userId), queryFn: ... });
// Invalidate all user queries
queryClient.invalidateQueries({ queryKey: queryKeys.all });
// Invalidate only list queries
queryClient.invalidateQueries({ queryKey: queryKeys.lists() });Fix 3: Fix Mutations and Cache Updates
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreateUserForm() {
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: async (newUser: CreateUserInput) => {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newUser),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<User>;
},
// Option 1: Invalidate the cache — triggers a refetch
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
// Option 2: Manually update the cache — no extra request
onSuccess: (newUser) => {
queryClient.setQueryData<User[]>(['users'], (old) => {
return old ? [...old, newUser] : [newUser];
});
},
onError: (error) => {
console.error('Failed to create user:', error);
},
});
return (
<form onSubmit={(e) => {
e.preventDefault();
const data = new FormData(e.target as HTMLFormElement);
createMutation.mutate({
name: data.get('name') as string,
email: data.get('email') as string,
});
}}>
<input name="name" />
<input name="email" />
<button disabled={createMutation.isPending}>
{createMutation.isPending ? 'Creating...' : 'Create User'}
</button>
{createMutation.isError && (
<p>Error: {createMutation.error.message}</p>
)}
</form>
);
}Optimistic updates — show changes immediately:
const updateMutation = useMutation({
mutationFn: (update: { id: number; name: string }) =>
fetch(`/api/users/${update.id}`, {
method: 'PATCH',
body: JSON.stringify({ name: update.name }),
}).then(r => r.json()),
onMutate: async (update) => {
// Cancel any in-flight refetches
await queryClient.cancelQueries({ queryKey: ['users'] });
// Snapshot the current value
const previous = queryClient.getQueryData<User[]>(['users']);
// Optimistically update the cache
queryClient.setQueryData<User[]>(['users'], (old) =>
old?.map(u => u.id === update.id ? { ...u, name: update.name } : u) ?? []
);
// Return snapshot for rollback
return { previous };
},
onError: (err, update, context) => {
// Rollback on error
if (context?.previous) {
queryClient.setQueryData(['users'], context.previous);
}
},
onSettled: () => {
// Always refetch after mutation to sync with server
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});Fix 4: Fix Infinite Queries for Pagination
import { useInfiniteQuery } from '@tanstack/react-query';
interface UsersPage {
users: User[];
nextCursor: string | null;
}
function InfiniteUserList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['users', 'infinite'],
queryFn: async ({ pageParam }) => {
const res = await fetch(`/api/users?cursor=${pageParam ?? ''}`);
return res.json() as Promise<UsersPage>;
},
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
// getNextPageParam returns undefined to stop fetching
});
// Flatten pages into a single array
const users = data?.pages.flatMap(page => page.users) ?? [];
return (
<>
<ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : hasNextPage ? 'Load More' : 'No More'}
</button>
</>
);
}Fix 5: Use SSR and Prefetching
For Next.js and other SSR frameworks, prefetch queries on the server:
// Next.js App Router — using TanStack Query with server components
// lib/query-client.ts
import { QueryClient } from '@tanstack/react-query';
import { cache } from 'react';
// Create a new QueryClient per request (cache() ensures one per request)
export const getQueryClient = cache(() => new QueryClient());// app/users/page.tsx (Server Component)
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import UserList from './UserList';
export default async function UsersPage() {
const queryClient = getQueryClient();
// Prefetch on the server
await queryClient.prefetchQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
return (
// Dehydrate the cache and send to client
<HydrationBoundary state={dehydrate(queryClient)}>
<UserList />
</HydrationBoundary>
);
}// app/users/UserList.tsx (Client Component)
'use client';
import { useQuery } from '@tanstack/react-query';
export default function UserList() {
// Data is already in cache from server prefetch — no loading state
const { data: users } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
return <ul>{users?.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}Providers setup for Next.js App Router:
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
export function Providers({ children }: { children: React.ReactNode }) {
// Create QueryClient inside component to avoid sharing state between users
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: { staleTime: 60 * 1000 },
},
}));
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}Fix 6: Custom Query Hooks and Query Factories
Organize queries into reusable hooks and factories:
// lib/queries/users.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { userKeys } from './keys';
import { userApi } from '../api/users';
// Query factory for type safety and reuse
export const userQueries = {
all: () => ({
queryKey: userKeys.all,
queryFn: userApi.getAll,
}),
detail: (id: number) => ({
queryKey: userKeys.detail(id),
queryFn: () => userApi.getById(id),
enabled: id > 0,
}),
byRole: (role: string) => ({
queryKey: userKeys.byRole(role),
queryFn: () => userApi.getByRole(role),
}),
};
// Hooks wrapping useQuery
export function useUsers() {
return useQuery(userQueries.all());
}
export function useUser(id: number) {
return useQuery(userQueries.detail(id));
}
// Mutation hook
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: userApi.create,
onSuccess: (user) => {
queryClient.invalidateQueries({ queryKey: userKeys.all });
queryClient.setQueryData(userKeys.detail(user.id), user);
},
});
}
// Prefetch function for use in loaders
export async function prefetchUsers(queryClient: QueryClient) {
await queryClient.prefetchQuery(userQueries.all());
}Still Not Working?
useQuery throws “No QueryClient set” — the component calling useQuery doesn’t have a QueryClientProvider ancestor. Check your component tree. Common issue: the provider is inside a condition or lazy-loaded component, so some render paths don’t have it. Move QueryClientProvider to the root of your app, above everything else.
Query refetches on every render — the queryKey contains an object or array that’s created inline and changes reference on every render:
// WRONG — new object every render
useQuery({
queryKey: ['users', { role: userRole }], // New object each render
queryFn: fetchUsers,
});
// CORRECT — stable reference
const filters = useMemo(() => ({ role: userRole }), [userRole]);
useQuery({
queryKey: ['users', filters],
queryFn: fetchUsers,
});
// OR — primitives don't have this problem
useQuery({
queryKey: ['users', userRole], // String is stable
queryFn: fetchUsers,
});isLoading vs isPending vs isFetching confusion — in TanStack Query v5:
isPending— query has no data yet (renamed fromisLoadingin v4)isLoading—isPending && isFetching(first fetch, no cached data)isFetching— any request in flight, including background refetches
Use isLoading to show a loading spinner for the first load, and isFetching to show a subtle “updating” indicator.
enabled flag toggles but query never runs — enabled is checked synchronously on each render. If it flips from false to true based on data from another query, that data must already be in the cache when the second query mounts. A common pattern is enabled: !!user?.id where user comes from another query — that works, but if you also wrapped the parent in Suspense, the child does not mount until the parent resolves, which is what you want. Mixing enabled with suspense: true produces order-of-operation bugs that are hard to spot.
Hydration mismatch in Next.js with prefetched queries — the server prefetched ['users'] but the client rendered <UserList /> with staleTime: 0. The cached data is served, then immediately refetched, and the second render produces different markup than the SSR output. Set staleTime greater than 0 inside the client provider so the prefetched data is considered fresh on hydration, and use HydrationBoundary (not the deprecated Hydrate wrapper) for the dehydrated state.
Mutations land in DevTools but onSuccess never fires — onSuccess only runs if the queryFn resolves without throwing. If your fetch returns 200 OK with { error: "..." } in the body, you need to throw inside the queryFn to mark it as error. TanStack Query treats any returned value (including null or an error-shaped object) as success unless the function throws. Add if (!res.ok) throw new Error(res.statusText) to every queryFn.
For related state management issues, see Fix: React Query Infinite Refetch, Fix: React Query Stale Data, Fix: SWR Not Working, and Fix: Zustand Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Jotai Not Working — Atom Not Updating, Derived Atom Out of Sync, or atomWithStorage Hydration Error
How to fix Jotai state management issues — atom scope, derived atoms, async atoms with Suspense, atomWithStorage SSR, useAtomValue vs useSetAtom, and debugging stale state.
Fix: Valtio Not Working — Component Not Re-rendering, Snapshot Stale, or Proxy Mutation Not Tracked
How to fix Valtio state management issues — proxy vs snapshot, useSnapshot for React, subscribe for side effects, derived state with computed, async actions, and Valtio with React Server Components.
Fix: Zustand Not Working — Component Not Re-rendering, State Reset on Refresh, or Selector Causing Infinite Loop
How to fix Zustand state management issues — selector optimization, persist middleware, shallow comparison, devtools setup, slice pattern for large stores, and common subscription mistakes.
Fix: AutoAnimate Not Working — Transitions Not Playing, List Items Not Animating, or React State Changes Ignored
How to fix @formkit/auto-animate issues — parent ref setup, React useAutoAnimate hook, Vue directive, animation customization, disabling for specific elements, and framework integration.