React TanStack Query: Server State and Caching Guide

TanStack Query (formerly React Query) is the standard solution for managing server state in React applications. Unlike Redux or Zustand, it is built specifically for asynchronous data — fetching, caching, synchronizing, and updating server data with minimal boilerplate. It replaces the typical useEffect + useState pattern for data fetching and adds background refetching, stale-while-revalidate caching, automatic retry, pagination, and optimistic updates out of the box.

Setup and QueryClient

TanStack Query requires a QueryClient instance and a QueryClientProvider at the root of your application. The QueryClient holds the cache and configuration defaults. Install the devtools for an in-browser cache inspector during development.

npm install @tanstack/react-query @tanstack/react-query-devtools

// main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,   // data stays fresh for 5 minutes
      gcTime: 1000 * 60 * 10,     // cache garbage collected after 10 min
      retry: 2,                    // retry failed requests twice
      refetchOnWindowFocus: true,  // re-fetch when tab regains focus
    },
    mutations: {
      retry: 0,
    },
  },
})

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Router />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  )
}
Note: staleTime is the most important setting to tune. Data is considered stale after this period and will be refetched in the background on next access. Set it to Infinity for static reference data that never changes.

useQuery: Fetching Data

useQuery takes a query key and an async function. It returns data, isPending, isError, error, isFetching, and more. The query runs automatically on mount, retries on failure, and refetches in the background when stale.

import { useQuery } from '@tanstack/react-query'

// Basic fetch
function UserProfile({ userId }) {
  const { data: user, isPending, isError, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(r => {
      if (!r.ok) throw new Error('Failed to fetch user')
      return r.json()
    }),
  })

  if (isPending) return <Skeleton />
  if (isError) return <ErrorMessage message={error.message} />

  return <div>{user.name}</div>
}

// With axios and typed response
interface Post { id: number; title: string; body: string }

async function fetchPost(id: number): Promise<Post> {
  const { data } = await axios.get<Post>(`/api/posts/${id}`)
  return data
}

function PostDetail({ postId }: { postId: number }) {
  const {
    data: post,
    isPending,
    isError,
    isFetching,    // true even during background refetch
    dataUpdatedAt, // timestamp of last successful fetch
  } = useQuery({
    queryKey: ['post', postId],
    queryFn: () => fetchPost(postId),
    staleTime: 1000 * 30,         // override global default
    enabled: postId > 0,          // only fetch when postId is valid
    select: (data) => ({          // transform/select from data
      ...data,
      titleUpper: data.title.toUpperCase(),
    }),
  })

  return (
    <div>
      {isFetching && <span className="updating-badge">Updating...</span>}
      {post && <article><h1>{post.titleUpper}</h1><p>{post.body}</p></article>}
    </div>
  )
}

// Parallel queries
function Dashboard() {
  const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers })
  const statsQuery = useQuery({ queryKey: ['stats'], queryFn: fetchStats })
  const notifQuery = useQuery({ queryKey: ['notifications'], queryFn: fetchNotifications })

  if (usersQuery.isPending || statsQuery.isPending) return <Spinner />

  return <DashboardLayout users={usersQuery.data} stats={statsQuery.data} />
}

Query Keys and Dependencies

Query keys are the cache key — any change in the key triggers a new fetch. Always include every variable the query depends on in the key. This makes dependent queries, search, and filter behavior automatic: change the key, and TanStack Query fetches the new data while serving cached data for the old key.

// Key conventions — arrays, nested objects allowed
['posts']                             // list of all posts
['post', 42]                         // single post
['posts', { status: 'published' }]   // filtered list
['user', userId, 'posts']            // user's posts

// Dependent query — only runs when userId is available
function UserPosts({ userId }) {
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  })

  const { data: posts } = useQuery({
    queryKey: ['posts', { authorId: user?.id }],
    queryFn: () => fetchPosts({ authorId: user.id }),
    enabled: !!user,   // blocks until user is loaded
  })

  return <PostList posts={posts} />
}

// Search with debounce
function ProductSearch() {
  const [search, setSearch] = useState('')
  const [debouncedSearch] = useDebounce(search, 300)

  const { data } = useQuery({
    queryKey: ['products', { search: debouncedSearch }],
    queryFn: () => searchProducts(debouncedSearch),
    enabled: debouncedSearch.length > 2,
    placeholderData: keepPreviousData,  // keep old results visible while fetching new
  })

  return (
    <div>
      <input value={search} onChange={e => setSearch(e.target.value)} />
      <ProductGrid products={data} />
    </div>
  )
}

// Query factory — reusable key + fn definitions
const postQueries = {
  all: () => ({ queryKey: ['posts'], queryFn: fetchAllPosts }),
  detail: (id) => ({ queryKey: ['post', id], queryFn: () => fetchPost(id) }),
  byAuthor: (authorId) => ({
    queryKey: ['posts', { authorId }],
    queryFn: () => fetchPostsByAuthor(authorId),
  }),
}

// Usage
const { data } = useQuery(postQueries.detail(postId))

useMutation: Writing Data

useMutation handles POST, PUT, PATCH, DELETE operations. It provides isPending, isSuccess, isError states and callbacks for side effects. After a successful mutation, invalidate related queries to trigger background refetches and keep the cache in sync.

import { useMutation, useQueryClient } from '@tanstack/react-query'

function CreatePostForm() {
  const queryClient = useQueryClient()

  const { mutate, isPending, isError, error } = useMutation({
    mutationFn: (newPost) => axios.post('/api/posts', newPost).then(r => r.data),

    onSuccess: (createdPost) => {
      // Invalidate the posts list so it refetches
      queryClient.invalidateQueries({ queryKey: ['posts'] })

      // OR directly update the cache without a refetch
      queryClient.setQueryData(['post', createdPost.id], createdPost)

      toast.success('Post created!')
    },

    onError: (error) => {
      toast.error(`Failed: ${error.message}`)
    },

    onSettled: () => {
      // Runs on both success and error
      queryClient.invalidateQueries({ queryKey: ['posts'] })
    },
  })

  function handleSubmit(e) {
    e.preventDefault()
    const formData = new FormData(e.target)
    mutate({ title: formData.get('title'), body: formData.get('body') })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" placeholder="Title" />
      <textarea name="body" placeholder="Content" />
      {isError && <p className="error">{error.message}</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? 'Saving...' : 'Publish'}
      </button>
    </form>
  )
}

// mutateAsync for sequential operations
const { mutateAsync: deletePost } = useMutation({
  mutationFn: (id) => axios.delete(`/api/posts/${id}`),
})

async function handleBulkDelete(ids) {
  try {
    await Promise.all(ids.map(id => deletePost(id)))
    queryClient.invalidateQueries({ queryKey: ['posts'] })
    toast.success(`Deleted ${ids.length} posts`)
  } catch {
    toast.error('Some deletions failed')
  }
}

Cache Configuration and Invalidation

The TanStack Query cache is a key-value store where values are query results with metadata. You can read, write, and invalidate cache entries programmatically. Understanding staleTime vs gcTime is critical: stale data is refetched in the background but still shown; garbage-collected data is completely removed and triggers a loading state on next access.

const queryClient = useQueryClient()

// Invalidate — marks as stale, triggers background refetch on next access
queryClient.invalidateQueries({ queryKey: ['posts'] })            // all post queries
queryClient.invalidateQueries({ queryKey: ['post', 42] })         // specific post
queryClient.invalidateQueries({ queryKey: ['posts'], exact: false }) // prefix match

// Prefetch — populates cache before user navigates
async function prefetchPost(id) {
  await queryClient.prefetchQuery({
    queryKey: ['post', id],
    queryFn: () => fetchPost(id),
    staleTime: 1000 * 60,
  })
}

// On hover — prefetch before click
<Link
  to={`/posts/${post.id}`}
  onMouseEnter={() => prefetchPost(post.id)}
>
  {post.title}
</Link>

// setQueryData — write directly to cache (no network request)
queryClient.setQueryData(['user', userId], (old) => ({
  ...old,
  name: 'Updated Name',
}))

// getQueryData — read from cache synchronously
const cachedUser = queryClient.getQueryData(['user', userId])

// cancelQueries — cancel in-flight requests before optimistic update
await queryClient.cancelQueries({ queryKey: ['todos'] })

Pagination and Infinite Queries

TanStack Query has dedicated support for cursor-based and offset pagination. keepPreviousData (now placeholderData: keepPreviousData) prevents the loading flash when paging. useInfiniteQuery manages load-more patterns with automatic page concatenation.

import { useInfiniteQuery, keepPreviousData } from '@tanstack/react-query'

// Offset pagination
function PaginatedPosts() {
  const [page, setPage] = useState(1)

  const { data, isPending, isPlaceholderData } = useQuery({
    queryKey: ['posts', { page }],
    queryFn: () => fetchPosts({ page, limit: 10 }),
    placeholderData: keepPreviousData,  // show old data while fetching next page
  })

  return (
    <div>
      {isPending ? <Spinner /> : <PostList posts={data.posts} />}
      <div>
        <button onClick={() => setPage(p => p - 1)} disabled={page === 1}>Prev</button>
        <span> Page {page} of {data?.totalPages} </span>
        <button
          onClick={() => setPage(p => p + 1)}
          disabled={isPlaceholderData || page === data?.totalPages}
        >Next</button>
      </div>
    </div>
  )
}

// Infinite / load-more
function InfiniteFeed() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isPending,
  } = useInfiniteQuery({
    queryKey: ['feed'],
    queryFn: ({ pageParam }) => fetchFeed({ cursor: pageParam, limit: 20 }),
    initialPageParam: null,
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
  })

  const posts = data?.pages.flatMap(page => page.posts) ?? []

  return (
    <div>
      {isPending && <Spinner />}
      {posts.map(post => <PostCard key={post.id} post={post} />)}
      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? 'Loading...' : 'Load more'}
        </button>
      )}
    </div>
  )
}

Optimistic Updates

Optimistic updates immediately apply a mutation to the UI before the server confirms it. If the server rejects, TanStack Query rolls back to the previous cache value automatically using the onMutate / onError pattern.

function TodoList() {
  const queryClient = useQueryClient()

  const toggleTodo = useMutation({
    mutationFn: (todo) => axios.patch(`/api/todos/${todo.id}`, { done: !todo.done }),

    // 1. Cancel outgoing fetches to prevent race conditions
    onMutate: async (todo) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] })

      // 2. Snapshot current value for rollback
      const previous = queryClient.getQueryData(['todos'])

      // 3. Optimistically update the cache
      queryClient.setQueryData(['todos'], (old) =>
        old.map(t => t.id === todo.id ? { ...t, done: !t.done } : t)
      )

      return { previous }
    },

    // 4. Rollback on error
    onError: (err, todo, context) => {
      queryClient.setQueryData(['todos'], context.previous)
      toast.error('Update failed — rolled back')
    },

    // 5. Always refetch after settle for consistency
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    },
  })

  return <TodoItems onToggle={(todo) => toggleTodo.mutate(todo)} />
}

Advanced Patterns

Custom hooks that wrap useQuery and useMutation are the cleanest way to organise server state. Colocate the query key, fetch function, and any data transformation in one place. Components then import a named hook and never deal with raw query keys or fetch URLs directly.

// Custom hooks encapsulate all query logic
export function useUser(userId) {
  return useQuery({
    queryKey: userKeys.detail(userId),
    queryFn: () => userApi.getById(userId),
    enabled: !!userId,
  })
}

export function useUpdateUser() {
  const queryClient = useQueryClient()
  return useMutation({
    mutationFn: ({ id, data }) => userApi.update(id, data),
    onSuccess: (user) => {
      queryClient.setQueryData(userKeys.detail(user.id), user)
      queryClient.invalidateQueries({ queryKey: userKeys.lists() })
    },
  })
}

// Suspense integration (React 18+)
function SuspensePosts() {
  const { data } = useSuspenseQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  })
  // No loading check needed — Suspense boundary handles it
  return <PostList posts={data} />
}

function Page() {
  return (
    <Suspense fallback={<Skeleton />}>
      <ErrorBoundary fallback={<ErrorFallback />}>
        <SuspensePosts />
      </ErrorBoundary>
    </Suspense>
  )
}

// Server-side with Next.js App Router
// app/posts/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'

export default async function PostsPage() {
  const queryClient = new QueryClient()
  await queryClient.prefetchQuery({ queryKey: ['posts'], queryFn: fetchPosts })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <ClientPostList />  {/* picks up prefetched data instantly */}
    </HydrationBoundary>
  )
}