Skip to content

Fix: TanStack Query Not Working — Data Not Fetching, Cache Not Updating, or Mutation Not Triggering Re-render

FixDevs · (Updated: )

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 runs

Or 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.

  • queryFn is required — if queryFn is undefined or the enabled flag is false, the query never fires. No error is thrown — it just stays in the pending state.
  • QueryClient must be provided at the root — every component that calls useQuery must have a QueryClientProvider ancestor. 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 from isLoading in v4)
  • isLoadingisPending && 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 runsenabled 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 firesonSuccess 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles