React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

tanstack-queryreact-querycachingdata-fetchingmutationsssr

TanStack Query - Server state management with caching, background refetching, and optimistic updates

Recipe

npm install @tanstack/react-query
// app/providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
 
export function QueryProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 60 * 1000, // 1 minute
            refetchOnWindowFocus: false,
          },
        },
      })
  );
 
  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
}
// app/layout.tsx
import { QueryProvider } from "./providers";
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <QueryProvider>{children}</QueryProvider>
      </body>
    </html>
  );
}
"use client";
import { useQuery } from "@tanstack/react-query";
 
function usePosts() {
  return useQuery({
    queryKey: ["posts"],
    queryFn: async () => {
      const res = await fetch("/api/posts");
      if (!res.ok) throw new Error("Failed to fetch posts");
      return res.json() as Promise<Post[]>;
    },
  });
}

When to reach for this: You need client-side data fetching with automatic caching, background refetching, loading/error states, and cache invalidation after mutations.

Working Example

// app/components/PostManager.tsx
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
 
interface Post {
  id: number;
  title: string;
  body: string;
}
 
function usePosts() {
  return useQuery({
    queryKey: ["posts"],
    queryFn: async (): Promise<Post[]> => {
      const res = await fetch("https://jsonplaceholder.typicode.com/posts?_limit=10");
      if (!res.ok) throw new Error("Failed to fetch");
      return res.json();
    },
  });
}
 
function useCreatePost() {
  const queryClient = useQueryClient();
 
  return useMutation({
    mutationFn: async (newPost: { title: string; body: string }) => {
      const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(newPost),
      });
      return res.json() as Promise<Post>;
    },
    onMutate: async (newPost) => {
      // Cancel in-flight queries
      await queryClient.cancelQueries({ queryKey: ["posts"] });
 
      // Snapshot previous value
      const previousPosts = queryClient.getQueryData<Post[]>(["posts"]);
 
      // Optimistically update
      queryClient.setQueryData<Post[]>(["posts"], (old) => [
        { id: Date.now(), ...newPost } as Post,
        ...(old ?? []),
      ]);
 
      return { previousPosts };
    },
    onError: (_err, _newPost, context) => {
      // Rollback on error
      queryClient.setQueryData(["posts"], context?.previousPosts);
    },
    onSettled: () => {
      // Refetch to ensure server state
      queryClient.invalidateQueries({ queryKey: ["posts"] });
    },
  });
}
 
export default function PostManager() {
  const { data: posts, isLoading, error } = usePosts();
  const createPost = useCreatePost();
 
  if (isLoading) return <div className="p-6">Loading posts...</div>;
  if (error) return <div className="p-6 text-red-600">Error: {error.message}</div>;
 
  return (
    <div className="max-w-2xl mx-auto p-6">
      <form
        onSubmit={(e) => {
          e.preventDefault();
          const form = e.target as HTMLFormElement;
          const title = (form.elements.namedItem("title") as HTMLInputElement).value;
          const body = (form.elements.namedItem("body") as HTMLTextAreaElement).value;
          createPost.mutate({ title, body });
          form.reset();
        }}
        className="mb-6 space-y-3"
      >
        <input
          name="title"
          placeholder="Post title"
          required
          className="w-full border rounded px-3 py-2"
        />
        <textarea
          name="body"
          placeholder="Post body"
          required
          rows={3}
          className="w-full border rounded px-3 py-2"
        />
        <button
          type="submit"
          disabled={createPost.isPending}
          className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
        >
          {createPost.isPending ? "Creating..." : "Create Post"}
        </button>
      </form>
 
      <ul className="space-y-3">
        {posts?.map((post) => (
          <li key={post.id} className="border rounded p-3">
            <h3 className="font-bold">{post.title}</h3>
            <p className="text-gray-600 text-sm mt-1">{post.body}</p>
          </li>
        ))}
      </ul>
    </div>
  );
}

What this demonstrates:

  • useQuery for data fetching with loading and error states
  • useMutation with optimistic updates and rollback on error
  • Cache invalidation with invalidateQueries after mutations
  • useQueryClient to access the cache directly
  • Custom hooks wrapping queries for reuse

Deep Dive

How It Works

  • TanStack Query manages a client-side cache keyed by queryKey arrays; identical keys share cached data
  • staleTime controls how long data is considered fresh; within this window, no refetch occurs
  • gcTime (formerly cacheTime) controls how long inactive cache entries are kept (default 5 minutes)
  • Background refetching happens automatically on window focus, network reconnect, and interval (if configured)
  • queryKey supports nested arrays and objects; keys are serialized and compared deeply
  • Mutations do not use the cache; they fire and forget, but can trigger cache updates via callbacks

Variations

Dependent queries (fetch B only after A completes):

function useUserPosts(userId: number | undefined) {
  return useQuery({
    queryKey: ["posts", userId],
    queryFn: () => fetch(`/api/users/${userId}/posts`).then((r) => r.json()),
    enabled: !!userId, // Only fetch when userId is available
  });
}
 
function UserPosts() {
  const { data: user } = useQuery({
    queryKey: ["user"],
    queryFn: () => fetch("/api/me").then((r) => r.json()),
  });
 
  const { data: posts } = useUserPosts(user?.id);
  // posts query waits until user is loaded
}

Infinite scroll / pagination:

import { useInfiniteQuery } from "@tanstack/react-query";
 
function useInfinitePosts() {
  return useInfiniteQuery({
    queryKey: ["posts", "infinite"],
    queryFn: async ({ pageParam }) => {
      const res = await fetch(`/api/posts?cursor=${pageParam}&limit=10`);
      return res.json() as Promise<{
        posts: Post[];
        nextCursor: string | null;
      }>;
    },
    initialPageParam: "",
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  });
}
 
function InfinitePostList() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useInfinitePosts();
 
  const allPosts = data?.pages.flatMap((page) => page.posts) ?? [];
 
  return (
    <div>
      {allPosts.map((post) => (
        <div key={post.id}>{post.title}</div>
      ))}
      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? "Loading more..." : "Load More"}
        </button>
      )}
    </div>
  );
}

Prefetching for instant navigation:

function PostList() {
  const queryClient = useQueryClient();
  const { data: posts } = usePosts();
 
  return (
    <ul>
      {posts?.map((post) => (
        <li
          key={post.id}
          onMouseEnter={() => {
            queryClient.prefetchQuery({
              queryKey: ["post", post.id],
              queryFn: () =>
                fetch(`/api/posts/${post.id}`).then((r) => r.json()),
            });
          }}
        >
          <Link href={`/posts/${post.id}`}>{post.title}</Link>
        </li>
      ))}
    </ul>
  );
}

SSR hydration with Next.js App Router:

// app/posts/page.tsx (Server Component)
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from "@tanstack/react-query";
import PostList from "./PostList";
 
export default async function PostsPage() {
  const queryClient = new QueryClient();
 
  await queryClient.prefetchQuery({
    queryKey: ["posts"],
    queryFn: async () => {
      const res = await fetch("https://jsonplaceholder.typicode.com/posts?_limit=10");
      return res.json();
    },
  });
 
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <PostList />
    </HydrationBoundary>
  );
}

TypeScript Notes

  • useQuery infers the data type from queryFn return type; annotate the function for explicit typing
  • queryKey accepts readonly unknown[]; use as const for strict tuple types
  • useMutation generics: useMutation<TData, TError, TVariables, TContext>
  • The select option transforms data and narrows the returned type
// Explicit typing with select
const { data } = useQuery({
  queryKey: ["posts"],
  queryFn: async (): Promise<Post[]> => {
    const res = await fetch("/api/posts");
    return res.json();
  },
  select: (posts) => posts.filter((p) => p.id > 5), // data is Post[]
});
 
// Query key factory pattern
const postKeys = {
  all: ["posts"] as const,
  lists: () => [...postKeys.all, "list"] as const,
  list: (filters: PostFilters) => [...postKeys.lists(), filters] as const,
  details: () => [...postKeys.all, "detail"] as const,
  detail: (id: number) => [...postKeys.details(), id] as const,
};

Gotchas

  • QueryClient in Server Component — Creating QueryClient outside a component or in a Server Component causes shared state between requests. Fix: Create QueryClient inside useState in the provider, or create a new instance per request in server prefetch functions.

  • staleTime vs gcTime confusionstaleTime controls refetch behavior; gcTime controls cache eviction. Setting staleTime: Infinity prevents refetches but the cache can still be garbage collected. Fix: Understand both: staleTime = "how long is data considered fresh?" gcTime = "how long to keep inactive cache entries?"

  • Query key must be serializable — Functions, class instances, or circular references in query keys cause issues. Fix: Use only strings, numbers, objects, and arrays in query keys.

  • Mutations don't auto-invalidate — Unlike some frameworks, mutations don't automatically refetch related queries. Fix: Use onSettled or onSuccess callback with queryClient.invalidateQueries().

  • Double fetching in Strict Mode — React Strict Mode in development mounts components twice, causing double fetches. Fix: This is expected behavior in dev mode only. In production, queries fetch once. TanStack Query deduplicates concurrent identical requests.

  • Optimistic update type mismatch — The optimistic data shape must match the query data exactly. Fix: Use queryClient.getQueryData<Post[]>() with the correct generic type, and ensure optimistic items have all required fields.

Alternatives

LibraryBest ForTrade-off
TanStack QueryComplex client-side data fetchingAdditional dependency, learning curve
SWRSimple data fetching with cachingFewer features (no mutations, no devtools)
Next.js Server ComponentsServer-side data fetchingNo client-side cache, no background refetch
RTK QueryRedux-based appsTied to Redux, heavier setup
Apollo ClientGraphQL APIsOverkill for REST, larger bundle

FAQs

What is queryKey and why does it matter?
  • queryKey is an array that uniquely identifies a query's cached data
  • Identical keys share the same cache entry and deduplicate concurrent requests
  • Keys are serialized and compared deeply, so ["posts", { status: "draft" }] works
  • Changing the key triggers a new fetch
What is the difference between staleTime and gcTime?
  • staleTime = how long data is considered fresh (no refetch during this window)
  • gcTime (formerly cacheTime) = how long inactive cache entries are kept before garbage collection
  • Setting staleTime: Infinity prevents all automatic refetches
  • Default gcTime is 5 minutes; after that, unmounted query data is removed
How do I invalidate the cache after a mutation?
const queryClient = useQueryClient();
 
const mutation = useMutation({
  mutationFn: createPost,
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ["posts"] });
  },
});

Use invalidateQueries in onSettled or onSuccess to refetch stale data.

How do optimistic updates work with useMutation?
  • onMutate: cancel in-flight queries, snapshot current data, update cache optimistically
  • onError: rollback to the snapshot if the mutation fails
  • onSettled: invalidate queries to refetch from the server regardless of success/failure
  • The optimistic data shape must match the query data exactly
Gotcha: Why does creating QueryClient outside a component cause shared state across requests?
  • A module-level QueryClient is shared between all server requests
  • One user's data could leak into another user's response
  • Fix: create QueryClient inside useState in the provider component
  • For server prefetching, create a new instance per request
How do I implement dependent queries (fetch B only after A)?
const { data: user } = useQuery({
  queryKey: ["user"],
  queryFn: fetchUser,
});
 
const { data: posts } = useQuery({
  queryKey: ["posts", user?.id],
  queryFn: () => fetchUserPosts(user!.id),
  enabled: !!user?.id,
});

The enabled option prevents the query from running until the dependency is available.

How do I set up SSR hydration with Next.js App Router?
  • Prefetch queries in a Server Component using a new QueryClient
  • Call dehydrate(queryClient) to serialize the cache
  • Wrap the client component with <HydrationBoundary state={dehydratedState}>
  • Client components using useQuery with the same key get instant data
How do I implement infinite scroll with useInfiniteQuery?
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
  queryKey: ["posts", "infinite"],
  queryFn: ({ pageParam }) =>
    fetch(`/api/posts?cursor=${pageParam}`).then(r => r.json()),
  initialPageParam: "",
  getNextPageParam: (lastPage) => lastPage.nextCursor,
});
 
const allPosts = data?.pages.flatMap(p => p.posts) ?? [];
Gotcha: Why do I see double fetches in development mode?
  • React Strict Mode mounts components twice in development, triggering two fetches
  • This only happens in dev mode, not in production
  • TanStack Query deduplicates concurrent identical requests automatically
  • Do not disable Strict Mode to work around this
How do I type useQuery and useMutation properly in TypeScript?
// useQuery infers data type from queryFn return type
const { data } = useQuery({
  queryKey: ["posts"],
  queryFn: async (): Promise<Post[]> => {
    const res = await fetch("/api/posts");
    return res.json();
  },
});
// data is Post[] | undefined
 
// useMutation generics: <TData, TError, TVariables, TContext>
const mutation = useMutation<Post, Error, { title: string }>({
  mutationFn: (vars) => createPost(vars),
});
What is the query key factory pattern and why use it?
const postKeys = {
  all: ["posts"] as const,
  lists: () => [...postKeys.all, "list"] as const,
  list: (filters: Filters) => [...postKeys.lists(), filters] as const,
  detail: (id: number) => [...postKeys.all, "detail", id] as const,
};
  • Centralizes key definitions to prevent typos and inconsistencies
  • Makes invalidation easier: invalidateQueries({ queryKey: postKeys.all }) clears all post queries
  • as const provides strict tuple types for better TypeScript inference
How do I prefetch data on hover for instant navigation?
<li
  onMouseEnter={() => {
    queryClient.prefetchQuery({
      queryKey: ["post", post.id],
      queryFn: () => fetchPost(post.id),
    });
  }}
>
  <Link href={`/posts/${post.id}`}>{post.title}</Link>
</li>

The data is cached before the user clicks, making the next page load instant.

  • SWR — Lighter alternative for simpler use cases
  • Next.js Server Components — Server-side fetching before hydration
  • Zustand — Client state management (separate from server state)
  • Prisma — Database access for the API endpoints that TanStack Query calls