Skip to content

TanStack Query v5 Reference: useQuery, useMutation, Cache Invalidation & Next.js

TanStack Query v5 (formerly React Query) manages server state: fetching, caching, synchronising, and updating remote data. The core insight is that server state is fundamentally different from client state — it can become stale, needs background refreshing, and can be shared across components. You stop writing loading/error/data state manually and start thinking in query keys. V5 breaking change: callbacks (onSuccess, onError, onSettled) were removed from useQuery — use useEffect or mutation callbacks instead.

1. Setup & useQuery

QueryClient, QueryClientProvider, useQuery with query keys, and status handling
// npm install @tanstack/react-query @tanstack/react-query-devtools

// main.tsx / app/providers.tsx:
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000,       // 1 minute before data is considered stale
      gcTime: 5 * 60 * 1000,      // 5 minutes before unused cache is garbage collected
      retry: 3,                    // retry failed requests 3 times
      retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000),
      refetchOnWindowFocus: true,  // refetch when tab is focused
    },
  },
});

export function Providers({ children }) {
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

// useQuery:
import { useQuery } from "@tanstack/react-query";

function UserProfile({ userId }: { userId: string }) {
  const { data, isLoading, isError, error, isFetching } = useQuery({
    queryKey: ["users", userId],    // cache key — array, first element = namespace
    queryFn: () => fetch(`/api/users/${userId}`).then(res => {
      if (!res.ok) throw new Error("Failed to fetch");
      return res.json() as Promise<User>;
    }),
    enabled: !!userId,             // don't fetch if userId is undefined
    staleTime: 5 * 60 * 1000,     // override default — 5min for user data
    select: (data) => data.user,  // transform data before component receives it
  });

  if (isLoading) return <Spinner />;
  if (isError) return <p>Error: {error.message}</p>;
  // isFetching is true during background refetches (data is still shown)
  return <div>{data.name} {isFetching && <LoadingDot />}</div>;
}

2. useMutation & Cache Invalidation

useMutation for writes, invalidateQueries to refetch, and optimistic updates
import { useMutation, useQueryClient } from "@tanstack/react-query";

function CreateUserForm() {
  const queryClient = useQueryClient();

  const createUser = useMutation({
    mutationFn: (newUser: { name: string; email: string }) =>
      fetch("/api/users", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(newUser),
      }).then(res => res.json()),

    // Invalidate after mutation — triggers refetch of users list:
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["users"] });
    },

    // Optimistic update pattern:
    onMutate: async (newUser) => {
      await queryClient.cancelQueries({ queryKey: ["users"] });  // cancel in-flight refetches
      const prev = queryClient.getQueryData(["users"]);          // snapshot
      queryClient.setQueryData(["users"], (old: User[]) => [
        ...(old ?? []),
        { ...newUser, id: "temp-" + Date.now() },
      ]);
      return { prev };   // return context for rollback
    },
    onError: (_err, _vars, context) => {
      queryClient.setQueryData(["users"], context?.prev);  // rollback
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ["users"] });  // always refetch after
    },
  });

  return (
    <button
      onClick={() => createUser.mutate({ name: "Alice", email: "alice@example.com" })}
      disabled={createUser.isPending}
    >
      {createUser.isPending ? "Creating..." : "Create User"}
    </button>
  );
}

3. Query Keys, Dependent Queries & Pagination

Query key factories, dependent queries, paginated and infinite scroll patterns
// Query key factory — consistent keys across the app:
const userKeys = {
  all:    ()           => ["users"]              as const,
  lists:  ()           => [...userKeys.all(), "list"]  as const,
  list:   (filters: F) => [...userKeys.lists(), { filters }] as const,
  detail: (id: string) => [...userKeys.all(), id]        as const,
};

// Use:
queryClient.invalidateQueries({ queryKey: userKeys.lists() });    // all list queries
queryClient.invalidateQueries({ queryKey: userKeys.detail("1") }); // specific user

// Dependent query — only fetch comments after user is loaded:
const { data: user } = useQuery({ queryKey: userKeys.detail(userId), queryFn: fetchUser });
const { data: comments } = useQuery({
  queryKey: ["comments", user?.id],
  queryFn: () => fetchComments(user!.id),
  enabled: !!user?.id,    // waits until user is loaded
});

// Paginated query (useQuery — replaces data on page change):
const [page, setPage] = useState(1);
const { data, isPlaceholderData } = useQuery({
  queryKey: ["users", "list", { page }],
  queryFn: () => fetchUsers(page),
  placeholderData: keepPreviousData,  // show old data while fetching next page
});

// Infinite scroll (useInfiniteQuery):
import { useInfiniteQuery } from "@tanstack/react-query";
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
  queryKey: ["users", "infinite"],
  queryFn: ({ pageParam }) => fetchUsers(pageParam),
  initialPageParam: 1,
  getNextPageParam: (lastPage, pages) => lastPage.hasMore ? pages.length + 1 : undefined,
});
// data.pages is array of pages; data.pages.flat() = all items

4. Prefetching, Suspense & Server State

Prefetch on hover, React Suspense integration, and Next.js server prefetching
// Prefetch on hover — pre-warm cache before user navigates:
function UserLink({ userId }: { userId: string }) {
  const queryClient = useQueryClient();
  return (
    <a
      href={`/users/${userId}`}
      onMouseEnter={() => {
        queryClient.prefetchQuery({
          queryKey: userKeys.detail(userId),
          queryFn: () => fetchUser(userId),
          staleTime: 10 * 60 * 1000,   // don't refetch if already fresh
        });
      }}
    >
      View Profile
    </a>
  );
}

// Suspense mode — let React Suspense handle loading state:
const { data } = useSuspenseQuery({
  queryKey: userKeys.detail(userId),
  queryFn: () => fetchUser(userId),
});
// No isLoading check needed — component suspends until data is ready
// Wrap with: <Suspense fallback={<Spinner />}><UserProfile /></Suspense>

// Next.js App Router — prefetch on server, hydrate on client:
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";
export default async function UsersPage() {
  const queryClient = new QueryClient();
  await queryClient.prefetchQuery({ queryKey: ["users"], queryFn: fetchUsers });
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <UsersClient />    {/* client component uses useQuery — data already in cache */}
    </HydrationBoundary>
  );
}

5. DevTools, Error Boundaries & V5 Migration

ReactQueryDevtools, error boundaries per query, and v4 → v5 breaking changes
// ReactQueryDevtools — browser panel showing cache state:
// Show in dev only, floating button bottom-right:
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-right" />

// Query-level error boundary:
import { QueryErrorResetBoundary } from "@tanstack/react-query";
import { ErrorBoundary } from "react-error-boundary";
<QueryErrorResetBoundary>
  {({ reset }) => (
    <ErrorBoundary
      fallbackRender={({ error, resetErrorBoundary }) => (
        <div>
          <p>{error.message}</p>
          <button onClick={resetErrorBoundary}>Retry</button>
        </div>
      )}
      onReset={reset}
    >
      <UserProfile userId={userId} />
    </ErrorBoundary>
  )}
</QueryErrorResetBoundary>

// V4 → V5 breaking changes (most common):
// cacheTime → gcTime
// useQuery onSuccess/onError/onSettled callbacks REMOVED — use useEffect or mutation callbacks
// isLoading → isPending (for mutations); isInitialLoading → isLoading && isFetching
// suspense: true → useSuspenseQuery()
// keepPreviousData: true → placeholderData: keepPreviousData
// getNextPageParam receives (lastPage, allPages, lastPageParam, allPageParams)
// Infinite query: initialPageParam required (was optional)

// Useful imperative API:
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.setQueryData(["users", userId], updater);
queryClient.removeQueries({ queryKey: ["users"] });
queryClient.cancelQueries({ queryKey: ["users"] });
const cached = queryClient.getQueryData(["users", userId]);

Track Node.js and React ecosystem releases at ReleaseRun. Related: React Reference | Next.js App Router Reference | tRPC Reference | React EOL Tracker

🔍 Free tool: npm Package Health Checker — check @tanstack/react-query and related packages for known CVEs and active maintenance.

Founded

2023 in London, UK

Contact

hello@releaserun.com