Skip to content

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

FixDevs ·

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 (React Query v5) manages server state with specific caching rules:

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

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.

For related state management issues, see Fix: React Query Infinite Refetch 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