Kazi Rahamatullah
Kazi Rahamatullah
AboutProjectsBlogContact
UsesBooks
ResumeView CV
Kazi Rahamatullah

© Copyright 2026 Kazi Rahamatullah

AboutProjectsBlogBooksUses
Twitter/XGitHubProduct HuntCodeSandbox
Back to Blog
ReactTanStack QueryData FetchingCachingFrontend Development

TanStack Query Deep Dive

Master TanStack Query — useQuery, useMutation, caching with staleTime and gcTime, query keys, pagination, infinite queries, prefetching, and production patterns for React apps.

Jul 3, 202614 min read
TanStack Query Deep Dive

Introduction

Fetching data in React used to mean wiring useEffect, useState, loading flags, error strings, and manual cache invalidation by hand. TanStack Query (formerly React Query) replaces that boilerplate with a declarative cache layer that handles background refetches, deduplication, retries, and stale-while-revalidate semantics out of the box.

This guide walks through the mental model from first useQuery call to production patterns:

  1. Setup — QueryClient, provider, and DevTools
  2. Data fetching — useQuery and query functions
  3. Mutations — useMutation, optimistic updates, and cache invalidation
  4. Caching & performance — staleTime, gcTime, and background refetching
  5. Advanced patterns — query keys, pagination, and infinite queries
  6. Best practices — prefetching, useQueryClient, and TanStack Query vs SWR

Each section includes copy-paste examples you can adapt to REST APIs, GraphQL, or server actions.

Note

TanStack Query is server-state management — not a replacement for client UI state (modals, form drafts, theme). Pair it with useState, Zustand, or Context for local state.

Quick index

#Section
1Project setup
2useQuery fundamentals
3Mutations & cache updates
4Caching & performance
5Query keys & dependencies
6Pagination & infinite queries
7Prefetching & useQueryClient
8TanStack Query vs SWR

1. Project setup

Install TanStack Query for React:

bash
npm install @tanstack/react-query
# optional — inspect cache, queries, and mutations in dev
npm install @tanstack/react-query-devtools

Wrap your app with a QueryClient and QueryClientProvider. Create one client per app (or per test) and reuse it across renders.

tsx
// app/providers.tsx
'use client'
 
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { useState, type ReactNode } from 'react'
 
export function Providers({ children }: { children: ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 60 * 1000, // 1 minute — data stays fresh
            retry: 1,
            refetchOnWindowFocus: false,
          },
        },
      }),
  )
 
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  )
}
tsx
// app/layout.tsx
import { Providers } from './providers'
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

Tip

Set sensible defaults on QueryClient so individual queries stay minimal. Override per-query only when behavior genuinely differs (e.g. real-time dashboards vs static reference data).

Back to index


2. useQuery fundamentals

useQuery takes a query key (unique cache identifier) and a query function (async fetcher). It returns data, isPending, isError, error, isFetching, and refetch.

tsx
'use client'
 
import { useQuery } from '@tanstack/react-query'
 
type Post = {
  id: number
  title: string
  body: string
}
 
async function fetchPosts(): Promise<Post[]> {
  const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=5')
  if (!res.ok) throw new Error('Failed to load posts')
  return res.json()
}
 
export function PostList() {
  const { data, isPending, isError, error, isFetching, refetch } = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  })
 
  if (isPending) return <p>Loading posts…</p>
  if (isError) return <p role="alert">Error: {error.message}</p>
 
  return (
    <section>
      <header className="flex items-center gap-3">
        <h2>Latest posts</h2>
        <button onClick={() => refetch()} disabled={isFetching}>
          {isFetching ? 'Refreshing…' : 'Refresh'}
        </button>
      </header>
      <ul>
        {data.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </section>
  )
}

Fetching a single resource by ID

tsx
export function PostDetail({ postId }: { postId: number }) {
  const { data, isPending, isError } = useQuery({
    queryKey: ['posts', postId],
    queryFn: async () => {
      const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`)
      if (!res.ok) throw new Error('Post not found')
      return res.json() as Promise<Post>
    },
    enabled: postId > 0, // skip fetch until ID is valid
  })
 
  if (isPending) return <p>Loading…</p>
  if (isError) return <p>Could not load post.</p>
 
  return (
    <article>
      <h1>{data.title}</h1>
      <p>{data.body}</p>
    </article>
  )
}

Note

isPending means no cached data yet and the first fetch is in flight. isFetching is true during any fetch — including background refetches when stale data is already on screen. Show a subtle indicator for isFetching, not a full-page loader.

Extracting fetch logic

Keep query functions pure and reusable — colocate them in a lib/api module or feature folder.

ts
// lib/posts.ts
export async function getPosts() {
  const res = await fetch('/api/posts')
  if (!res.ok) throw new Error('Failed to fetch posts')
  return res.json()
}
 
export async function getPost(id: number) {
  const res = await fetch(`/api/posts/${id}`)
  if (!res.ok) throw new Error('Failed to fetch post')
  return res.json()
}
tsx
const { data } = useQuery({
  queryKey: ['posts'],
  queryFn: getPosts,
})

Back to index


3. Mutations & cache updates

useMutation handles writes — POST, PUT, PATCH, DELETE. Unlike queries, mutations do not cache their result by default. You update the server, then invalidate or optimistically patch the cache.

tsx
'use client'
 
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
 
type Todo = { id: number; title: string; completed: boolean }
 
async function createTodo(title: string): Promise<Todo> {
  const res = await fetch('/api/todos', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title }),
  })
  if (!res.ok) throw new Error('Create failed')
  return res.json()
}
 
export function TodoApp() {
  const queryClient = useQueryClient()
 
  const { data: todos = [] } = useQuery({
    queryKey: ['todos'],
    queryFn: async () => {
      const res = await fetch('/api/todos')
      return res.json() as Promise<Todo[]>
    },
  })
 
  const createMutation = useMutation({
    mutationFn: createTodo,
    onSuccess: () => {
      // Refetch todos list after a successful create
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    },
  })
 
  return (
    <div>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
      <button onClick={() => createMutation.mutate('Learn TanStack Query')} disabled={createMutation.isPending}>
        {createMutation.isPending ? 'Saving…' : 'Add todo'}
      </button>
      {createMutation.isError && <p role="alert">{createMutation.error.message}</p>}
    </div>
  )
}

Optimistic updates

For snappy UX, update the cache before the server responds, then roll back on failure.

tsx
const toggleMutation = useMutation({
  mutationFn: (todo: Todo) =>
    fetch(`/api/todos/${todo.id}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ completed: !todo.completed }),
    }).then((r) => r.json()),
 
  onMutate: async (updatedTodo) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] })
    const previous = queryClient.getQueryData<Todo[]>(['todos'])
 
    queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
      old.map((t) => (t.id === updatedTodo.id ? { ...t, completed: !t.completed } : t)),
    )
 
    return { previous }
  },
 
  onError: (_err, _todo, context) => {
    queryClient.setQueryData(['todos'], context?.previous)
  },
 
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] })
  },
})

Warning

Always pair optimistic updates with onError rollback and onSettled invalidation. Without rollback, a failed request leaves the UI showing data that never reached the server.

Tip

Use onSuccess to invalidate related queries (e.g. after creating a post, invalidate both ['posts'] and ['posts', 'count']). Narrow invalidation with queryKey prefixes to avoid refetching unrelated data.

Back to index


4. Caching & performance

TanStack Query's power is its cache lifecycle. Every query moves through states: fresh → stale → garbage-collected.

OptionWhat it controls
staleTimeHow long cached data is considered fresh (no background refetch on mount/focus)
gcTimeHow long inactive data stays in memory after the last observer unmounts (v5; was cacheTime in v4)
retryAutomatic retry count on failure
refetchOnWindowFocusRefetch stale queries when the tab regains focus
tsx
const { data } = useQuery({
  queryKey: ['profile'],
  queryFn: fetchProfile,
  staleTime: 5 * 60 * 1000, // 5 min — profile rarely changes
  gcTime: 30 * 60 * 1000, // keep in memory 30 min after unmount
})

Stale-while-revalidate in practice

tsx
// User sees cached posts instantly; a stale refetch runs in the background
const { data, isFetching, dataUpdatedAt } = useQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
  staleTime: 30_000, // fresh for 30 seconds
})
 
return (
  <div>
    {isFetching && <span aria-live="polite">Updating…</span>}
    <p className="text-xs text-neutral-500">Last updated: {new Date(dataUpdatedAt).toLocaleTimeString()}</p>
    {/* render data */}
  </div>
)

Performance

High staleTime reduces network chatter for reference data (categories, settings). Low staleTime (or 0) suits dashboards and feeds where freshness matters. Tune per query — do not copy one global value everywhere.

Note

In TanStack Query v5, cacheTime was renamed to gcTime (garbage collection time). Search older tutorials for cacheTime — the behavior is the same, only the name changed.

Automatic retry

tsx
useQuery({
  queryKey: ['health'],
  queryFn: checkHealth,
  retry: 3,
  retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000), // exponential backoff
})

Back to index


5. Query keys & dependencies

Query keys are serializable arrays that uniquely identify cached data. Structure them from most general to most specific.

ts
// Good — hierarchical, predictable invalidation
;[
  'posts',
] // all posts
[
  ('posts', { status: 'draft' })
] // filtered list
[
  ('posts', postId)
] // single post
[
  ('posts', postId, 'comments')
] // nested resource
[
  // Avoid — unstable keys cause unnecessary refetches
  ('posts', new Date())
] // ❌ new key every render
[('posts', { filter })] // ⚠️ only if `filter` is a stable primitive object

Dependent queries

Fetch comments only after the post loads:

tsx
const { data: post } = useQuery({
  queryKey: ['posts', postId],
  queryFn: () => getPost(postId),
})
 
const { data: comments } = useQuery({
  queryKey: ['posts', postId, 'comments'],
  queryFn: () => getComments(postId),
  enabled: !!post, // runs only when post exists
})

placeholderData and initialData

tsx
// Show previous page data while the next page loads (pagination UX)
useQuery({
  queryKey: ['posts', page],
  queryFn: () => getPosts(page),
  placeholderData: (previousData) => previousData,
})
 
// Seed cache from SSR or localStorage
useQuery({
  queryKey: ['settings'],
  queryFn: fetchSettings,
  initialData: () => JSON.parse(localStorage.getItem('settings') ?? 'null'),
  staleTime: Infinity, // treat as fresh until explicitly invalidated
})

Tip

Use placeholderData for smooth transitions (pagination, filters). Use initialData when you already have trusted data (SSR props, persisted cache) and want to skip the loading state on first paint.

Back to index


6. Pagination & infinite queries

Offset pagination

tsx
'use client'
 
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
 
const PAGE_SIZE = 10
 
export function PaginatedPosts() {
  const [page, setPage] = useState(1)
 
  const { data, isPending, isPlaceholderData } = useQuery({
    queryKey: ['posts', 'list', page],
    queryFn: () => fetchPosts({ page, limit: PAGE_SIZE }),
    placeholderData: (prev) => prev,
    staleTime: 30_000,
  })
 
  return (
    <div>
      <ul>
        {data?.items.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
      <div className="flex gap-2">
        <button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page === 1}>
          Previous
        </button>
        <span>Page {page}</span>
        <button onClick={() => setPage((p) => p + 1)} disabled={isPlaceholderData || !data?.hasMore}>
          Next
        </button>
      </div>
      {isPending && <p>Loading…</p>}
    </div>
  )
}

Infinite scroll with useInfiniteQuery

tsx
import { useInfiniteQuery } from '@tanstack/react-query'
import { useEffect, useRef } from 'react'
 
export function InfinitePostFeed() {
  const loadMoreRef = useRef<HTMLDivElement>(null)
 
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } = useInfiniteQuery({
    queryKey: ['posts', 'infinite'],
    queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam }),
    initialPageParam: undefined as string | undefined,
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
  })
 
  useEffect(() => {
    const el = loadMoreRef.current
    if (!el || !hasNextPage) return
 
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0]?.isIntersecting && !isFetchingNextPage) {
          fetchNextPage()
        }
      },
      { rootMargin: '200px' },
    )
 
    observer.observe(el)
    return () => observer.disconnect()
  }, [fetchNextPage, hasNextPage, isFetchingNextPage])
 
  if (status === 'pending') return <p>Loading feed…</p>
 
  const posts = data.pages.flatMap((page) => page.items)
 
  return (
    <div>
      {posts.map((post) => (
        <article key={post.id}>{post.title}</article>
      ))}
      <div ref={loadMoreRef} className="h-8" />
      {isFetchingNextPage && <p>Loading more…</p>}
    </div>
  )
}

Performance

useInfiniteQuery keeps all loaded pages in one cache entry. For very long feeds, consider maxPages (v5) to trim old pages from memory, or switch to virtualized lists (e.g. @tanstack/react-virtual) so the DOM stays lean.

Back to index


7. Prefetching & useQueryClient

Prefetch data before the user navigates so the next screen renders instantly.

tsx
import { useQueryClient } from '@tanstack/react-query'
import Link from 'next/link'
 
export function PostCard({ post }: { post: Post }) {
  const queryClient = useQueryClient()
 
  const prefetch = () => {
    queryClient.prefetchQuery({
      queryKey: ['posts', post.id],
      queryFn: () => getPost(post.id),
      staleTime: 60_000,
    })
  }
 
  return (
    <Link href={`/blog/${post.slug}`} onMouseEnter={prefetch} onFocus={prefetch}>
      {post.title}
    </Link>
  )
}

Server-side prefetch (Next.js App Router)

tsx
// app/posts/[id]/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
import { PostDetail } from './PostDetail'
import { getPost } from '@/lib/posts'
 
export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const queryClient = new QueryClient()
 
  await queryClient.prefetchQuery({
    queryKey: ['posts', Number(id)],
    queryFn: () => getPost(Number(id)),
  })
 
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <PostDetail postId={Number(id)} />
    </HydrationBoundary>
  )
}

Tip

Prefetch on onMouseEnter and onFocus for link hover/focus — cheap wins without prefetching every route on page load. In Next.js, combine with HydrationBoundary so the client hydrates with server-prefetched cache.

Manual cache reads and writes

tsx
const queryClient = useQueryClient()
 
// Read synchronously (e.g. in an event handler)
const cached = queryClient.getQueryData<Post>(['posts', 42])
 
// Write without a network call
queryClient.setQueryData(['posts', 42], (old) => ({ ...old, title: 'Updated' }))
 
// Remove from cache
queryClient.removeQueries({ queryKey: ['posts', 42] })

Back to index


8. TanStack Query vs SWR

Both libraries solve async server state with stale-while-revalidate caching. The APIs differ; the mental model is similar.

FeatureTanStack QuerySWR
Cache scopeGlobal QueryClient with explicit keysGlobal SWR cache by key
MutationsFirst-class useMutation + invalidationmutate helper, less structured
DevToolsBuilt-in React Query DevtoolsLimited built-in tooling
Infinite scrolluseInfiniteQuery with page paramsuseSWRInfinite
EcosystemReact, Vue, Solid, Svelte adaptersReact-focused
Learning curveMore options, steeper initiallyMinimal API, faster to start
tsx
// SWR equivalent of a basic query
import useSWR from 'swr'
 
const fetcher = (url: string) => fetch(url).then((r) => r.json())
 
function PostListSWR() {
  const { data, error, isLoading, mutate } = useSWR('/api/posts', fetcher)
  // ...
}

Note

Choose TanStack Query when you need rich mutation flows, granular cache control, infinite pagination, or multi-framework support. Choose SWR when you want the smallest possible API for read-heavy pages with light invalidation.

Tip

Production checklist

  • One QueryClient per app with sensible defaultOptions
  • Structured, hierarchical query keys
  • enabled guard for dependent queries
  • Loading vs fetching UI (isPending vs isFetching)
  • Mutation invalidation or optimistic updates with rollback
  • Per-query staleTime tuned to data freshness needs
  • DevTools in development only
  • Prefetch on navigation intent (hover/focus) or SSR dehydrate

Interview Answer

"How does TanStack Query differ from putting fetch in useEffect?"

useEffect fetches on mount with no shared cache — navigate away and back, and you refetch from scratch. TanStack Query deduplicates requests by key, serves stale data instantly while revalidating in the background, handles retries, and gives you a single API for loading/error/success states. Mutations integrate with cache invalidation so lists stay in sync after writes.

Back to index


Summary

TanStack Query turns server state from scattered useEffect blocks into a predictable cache with explicit keys, lifecycle options, and mutation workflows. Start with useQuery + structured keys, add useMutation with invalidation, then tune staleTime and gcTime per resource. Prefetch and dehydrate when navigation latency matters.

Next reads: Next.js Caching & Rendering for how server caching complements client-side query caches, and MERN Course: React Essentials for broader React patterns.

Share this article

XLinkedInFacebook
Kazi Rahamatullah

Written by

Kazi Rahamatullah

FullStack Developer

X / TwitterGitHubLinkedIn

Subscribe to my newsletter

Stay up to date and get notified when I share new contents.

No spam ever, unsubscribe anytime