Frontend

TanStack Query: Data Fetching for React

Mayur Dabhi
Mayur Dabhi
June 18, 2026
14 min read

If you've ever wrestled with managing loading states, caching API responses, or keeping your UI in sync with a remote server, TanStack Query (formerly React Query) is the library that makes all of that feel effortless. It fundamentally changes how you think about server state in React — separating it from client state and giving you intelligent caching, background refetching, and synchronization out of the box.

In this guide, we'll go from zero to production-ready with TanStack Query v5, covering everything from basic data fetching to optimistic updates, infinite scroll, and cache invalidation strategies. Whether you're migrating from manual useEffect fetching or evaluating it against SWR or RTK Query, you'll leave with a complete understanding of when and how to use it effectively.

Why TanStack Query?

TanStack Query has over 40,000 GitHub stars and is downloaded more than 4 million times per week. It's the industry standard for server state management in React, used by companies like Vercel, Shopify, and Netflix. Version 5 (released in late 2023) brought a streamlined API, improved TypeScript support, and first-class support for streaming data.

What is TanStack Query?

TanStack Query is an asynchronous state management library for React (and now Vue, Solid, Angular, and Svelte via framework adapters). It solves a problem that most libraries ignore: server state is fundamentally different from client state.

Client state (think UI toggles, form values, selected tabs) lives in your app and you control it fully. Server state, on the other hand, lives on a remote server. You don't own it, it can change without your knowledge, and fetching it is asynchronous. TanStack Query is purpose-built for managing this complexity:

React Component useQuery() useMutation() QueryClient In-Memory Cache ['todos'] → [...] ['user', 1] → {...} stale / fresh / error Remote API REST / GraphQL fetch / axios reads cache fetch if stale update cache re-renders TanStack Query Data Flow

TanStack Query sits between your components and the network, managing cache, background refetching, and state

Installation & Setup

TanStack Query v5 requires React 18 or later and has zero required dependencies beyond React itself. Setup takes under five minutes.

1

Install the package

Install @tanstack/react-query and optionally the DevTools package for debugging.

Terminal
# npm
npm install @tanstack/react-query

# With DevTools (recommended for development)
npm install @tanstack/react-query @tanstack/react-query-devtools

# yarn / pnpm
yarn add @tanstack/react-query @tanstack/react-query-devtools
pnpm add @tanstack/react-query @tanstack/react-query-devtools
2

Wrap your app with QueryClientProvider

Create a QueryClient instance and provide it at the root of your application.

src/main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import ReactDOM from 'react-dom/client'
import App from './App'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,  // 5 minutes — data stays fresh before refetch
      retry: 2,                   // retry failed requests twice
      refetchOnWindowFocus: true, // refetch when user returns to tab
    },
  },
})

ReactDOM.createRoot(document.getElementById('root')!).render(
  <QueryClientProvider client={queryClient}>
    <App />
    <ReactQueryDevtools initialIsOpen={false} />
  </QueryClientProvider>
)
3

Start fetching data with useQuery

That's all the setup you need. Now any component in your tree can use useQuery to fetch and cache data.

staleTime vs gcTime

staleTime controls how long cached data is considered fresh (no network request made). gcTime (formerly cacheTime) controls how long unused cached data is kept in memory before garbage collection. They serve different purposes — tune staleTime first.

Querying Data with useQuery

The useQuery hook is the core of TanStack Query. It takes a query key (used for cache identification) and a query function (any async function returning a promise), and manages all the loading, error, and success states for you.

components/TodoList.tsx
import { useQuery } from '@tanstack/react-query'

interface Todo {
  id: number
  title: string
  completed: boolean
  userId: number
}

async function fetchTodos(): Promise<Todo[]> {
  const res = await fetch('https://jsonplaceholder.typicode.com/todos')
  if (!res.ok) throw new Error('Failed to fetch todos')
  return res.json()
}

function TodoList() {
  const {
    data: todos,
    isLoading,
    isError,
    error,
    isFetching,  // true when background refetch is happening
    refetch,
  } = useQuery({
    queryKey: ['todos'],        // unique cache key — array format preferred
    queryFn: fetchTodos,
    staleTime: 1000 * 60 * 2,  // override default: 2 minutes fresh
  })

  if (isLoading) return <p>Loading todos...</p>
  if (isError) return <p>Error: {error.message}</p>

  return (
    <div>
      <header>
        <h2>Todos {isFetching && '(updating...)'}</h2>
        <button onClick={() => refetch()}>Refresh</button>
      </header>
      <ul>
        {todos?.map(todo => (
          <li key={todo.id} style={{ opacity: todo.completed ? 0.5 : 1 }}>
            {todo.title}
          </li>
        ))}
      </ul>
    </div>
  )
}

Dynamic Query Keys

Query keys can include variables, which automatically re-trigger the query when those variables change. This is the pattern for fetching a specific resource by ID, or filtering results.

Dependent & Parameterized Queries
// Fetch a single user — reruns when userId changes
function useUser(userId: number) {
  return useQuery({
    queryKey: ['users', userId],          // key includes the variable
    queryFn: () => fetchUser(userId),
    enabled: !!userId,                    // skip if userId is falsy
  })
}

// Filtered list — reruns when filter changes
function useTodos(filter: 'all' | 'done' | 'pending') {
  return useQuery({
    queryKey: ['todos', { filter }],
    queryFn: () => fetchTodos(filter),
  })
}

// Dependent query — waits for a previous query to finish
function useUserPosts(userId?: number) {
  return useQuery({
    queryKey: ['posts', { userId }],
    queryFn: () => fetchPostsByUser(userId!),
    enabled: !!userId,                    // won't run until userId is available
  })
}

// Usage: chain two queries
function UserWithPosts({ userId }: { userId: number }) {
  const { data: user } = useUser(userId)
  const { data: posts } = useUserPosts(user?.id)  // enabled only after user loads
  // ...
}
Custom Hook Pattern

Wrap useQuery calls in custom hooks (like useUser above). This co-locates your query key and fetcher, makes the hook reusable across components, and keeps component code clean. It's the recommended pattern for any real application.

Mutations with useMutation

While useQuery handles reading data, useMutation handles write operations — POST, PUT, PATCH, DELETE. After a successful mutation, you typically invalidate related queries so they refetch with fresh data.

Mutation with Cache Invalidation
import { useMutation, useQueryClient } from '@tanstack/react-query'

async function createTodo(newTodo: { title: string }): Promise<Todo> {
  const res = await fetch('/api/todos', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(newTodo),
  })
  if (!res.ok) throw new Error('Failed to create todo')
  return res.json()
}

function AddTodoForm() {
  const queryClient = useQueryClient()
  const [title, setTitle] = useState('')

  const mutation = useMutation({
    mutationFn: createTodo,

    // Called immediately on success — before the response propagates
    onSuccess: (newTodo) => {
      // Invalidate and refetch the todos list
      queryClient.invalidateQueries({ queryKey: ['todos'] })

      // OR: manually update the cache without a network request
      queryClient.setQueryData<Todo[]>(['todos'], (old = []) => [
        ...old,
        newTodo,
      ])
    },

    onError: (error) => {
      console.error('Create failed:', error)
    },
  })

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    if (!title.trim()) return
    mutation.mutate({ title })
    setTitle('')
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={title}
        onChange={e => setTitle(e.target.value)}
        placeholder="New todo..."
        disabled={mutation.isPending}
      />
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? 'Adding...' : 'Add Todo'}
      </button>
      {mutation.isError && (
        <p style={{ color: 'red' }}>Error: {mutation.error.message}</p>
      )}
    </form>
  )
}

Optimistic Updates

Optimistic updates show the result of a mutation immediately — before the server confirms it — giving users instant feedback. If the mutation fails, TanStack Query rolls back the cache to its previous state automatically.

Optimistic Todo Toggle
const toggleTodo = useMutation({
  mutationFn: ({ id, completed }: { id: number; completed: boolean }) =>
    fetch(`/api/todos/${id}`, {
      method: 'PATCH',
      body: JSON.stringify({ completed }),
    }).then(r => r.json()),

  onMutate: async ({ id, completed }) => {
    // Cancel any outgoing refetches so they don't overwrite optimistic update
    await queryClient.cancelQueries({ queryKey: ['todos'] })

    // Snapshot current value for rollback
    const previousTodos = queryClient.getQueryData<Todo[]>(['todos'])

    // Optimistically update cache
    queryClient.setQueryData<Todo[]>(['todos'], old =>
      old?.map(todo => (todo.id === id ? { ...todo, completed } : todo)) ?? []
    )

    // Return snapshot for onError rollback
    return { previousTodos }
  },

  onError: (_err, _vars, context) => {
    // Roll back to snapshot if mutation fails
    if (context?.previousTodos) {
      queryClient.setQueryData(['todos'], context.previousTodos)
    }
  },

  onSettled: () => {
    // Always refetch to ensure cache is in sync with server
    queryClient.invalidateQueries({ queryKey: ['todos'] })
  },
})

Advanced Features

Use keepPreviousData (v5: placeholderData: keepPreviousData) for smooth page transitions — the old page stays visible while the new one loads.

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

function PaginatedList() {
  const [page, setPage] = useState(1)

  const { data, isFetching } = useQuery({
    queryKey: ['users', page],
    queryFn: () => fetchUsers({ page, limit: 10 }),
    placeholderData: keepPreviousData, // show old data while fetching new page
  })

  return (
    <div>
      <ul>
        {data?.users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
      <nav>
        <button onClick={() => setPage(p => p - 1)} disabled={page === 1}>
          Previous
        </button>
        <span>Page {page}</span>
        <button
          onClick={() => setPage(p => p + 1)}
          disabled={!data?.hasMore}
        >
          {isFetching ? 'Loading...' : 'Next'}
        </button>
      </nav>
    </div>
  )
}

useInfiniteQuery is designed for "load more" and infinite scroll patterns. Each page's data is stored separately but returned as a flattened structure.

import { useInfiniteQuery } from '@tanstack/react-query'
import { useInView } from 'react-intersection-observer'

function InfiniteList() {
  const { ref, inView } = useInView()

  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['infinite-users'],
    queryFn: ({ pageParam }) => fetchUsers({ cursor: pageParam }),
    initialPageParam: undefined,
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
  })

  // Auto-fetch next page when sentinel div enters viewport
  useEffect(() => {
    if (inView && hasNextPage) fetchNextPage()
  }, [inView, hasNextPage])

  return (
    <div>
      {data?.pages.map((page, i) => (
        <Fragment key={i}>
          {page.users.map(user => (
            <div key={user.id}>{user.name}</div>
          ))}
        </Fragment>
      ))}
      {/* Sentinel element — triggers load when visible */}
      <div ref={ref}>
        {isFetchingNextPage && 'Loading more...'}
        {!hasNextPage && 'No more users'}
      </div>
    </div>
  )
}

Multiple useQuery calls in the same component run in parallel automatically. For dynamic numbers of parallel queries, use useQueries.

// Two queries fire in parallel — no extra setup needed
function Dashboard() {
  const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers })
  const statsQuery = useQuery({ queryKey: ['stats'], queryFn: fetchStats })

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

  return (
    <div>
      <StatsPanel data={statsQuery.data} />
      <UserTable data={usersQuery.data} />
    </div>
  )
}

// Dynamic number of parallel queries with useQueries
function MultiUserDetails({ userIds }: { userIds: number[] }) {
  const results = useQueries({
    queries: userIds.map(id => ({
      queryKey: ['users', id],
      queryFn: () => fetchUser(id),
    })),
  })

  const allLoaded = results.every(r => r.isSuccess)

  return allLoaded ? (
    <ul>
      {results.map((r, i) => (
        <li key={userIds[i]}>{r.data?.name}</li>
      ))}
    </ul>
  ) : <Spinner />
}

Prefetch data before a user navigates to a route. When they arrive, the data is already cached and renders instantly with no loading state.

// Prefetch on hover — data is ready when user clicks
function NavLink({ userId }: { userId: number }) {
  const queryClient = useQueryClient()

  const prefetchUser = () => {
    queryClient.prefetchQuery({
      queryKey: ['users', userId],
      queryFn: () => fetchUser(userId),
      staleTime: 1000 * 60, // don't refetch if already fresh
    })
  }

  return (
    <a
      href={`/users/${userId}`}
      onMouseEnter={prefetchUser}  // prefetch on hover
    >
      View User
    </a>
  )
}

// Server-side prefetching with Next.js App Router
// app/users/page.tsx
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)}>
      <UserList />  {/* renders with data already in cache */}
    </HydrationBoundary>
  )
}

TanStack Query vs Alternatives

Choosing the right data fetching solution depends on your stack and requirements. Here's how the major options compare:

Feature TanStack Query v5 SWR RTK Query
Bundle size (minzipped) ~13 KB ~4 KB ~9 KB (part of RTK)
Mutations First-class useMutation Manual (trigger fn) First-class endpoints
Infinite queries Built-in useInfiniteQuery Built-in useSWRInfinite Manual with serializeQueryArgs
Cache granularity Per query key (flexible) Per URL key Per endpoint + args
Optimistic updates Built-in with rollback Via mutate(data, false) Built-in via onQueryStarted
DevTools Excellent visual inspector None official Redux DevTools integration
Framework support React, Vue, Solid, Angular React only React (via Redux)
Best for Complex apps, multiple frameworks Simple apps, minimal boilerplate Redux-heavy codebases
TanStack Query wins on features and DevTools. SWR wins on bundle size. RTK Query wins if you're already deep in Redux. Most new projects should default to TanStack Query.

Error Handling & Retry Logic

TanStack Query retries failed queries automatically (3 times by default) with exponential backoff. You can customize this globally or per-query, and use error boundaries to catch and display errors declaratively.

Advanced Error Handling
// Per-query retry configuration
const { data, error } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  retry: (failureCount, error) => {
    // Don't retry on 404 or 401
    if (error.status === 404 || error.status === 401) return false
    return failureCount < 3
  },
  retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
})

// Global error handling with QueryCache
const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error, query) => {
      if (query.state.data !== undefined) {
        // Only show toast if we had data — background refetch failure
        toast.error(`Background refresh failed: ${error.message}`)
      }
    },
  }),
  mutationCache: new MutationCache({
    onError: (error) => {
      toast.error(`Action failed: ${error.message}`)
    },
  }),
})

// Error boundary for declarative error handling
import { QueryErrorResetBoundary } from '@tanstack/react-query'
import { ErrorBoundary } from 'react-error-boundary'

function App() {
  return (
    <QueryErrorResetBoundary>
      {({ reset }) => (
        <ErrorBoundary
          onReset={reset}
          fallbackRender={({ resetErrorBoundary }) => (
            <div>
              <p>Something went wrong!</p>
              <button onClick={resetErrorBoundary}>Try again</button>
            </div>
          )}
        >
          <UserList />
        </ErrorBoundary>
      )}
    </QueryErrorResetBoundary>
  )
}

Best Practices

Query Key Conventions

  • Use arrays for all query keys: ['users'], ['users', id], ['users', { status: 'active' }]
  • Put the most general identifiers first, specific last — this makes selective invalidation easy
  • Extract key factories to avoid typos and ease invalidation: userKeys.detail(id)
Query Key Factory Pattern
// Define all keys in one place
const userKeys = {
  all: ['users'] as const,
  lists: () => [...userKeys.all, 'list'] as const,
  list: (filters: object) => [...userKeys.lists(), filters] as const,
  details: () => [...userKeys.all, 'detail'] as const,
  detail: (id: number) => [...userKeys.details(), id] as const,
}

// Usage — typo-safe, easy to invalidate
const { data } = useQuery({
  queryKey: userKeys.detail(userId),  // ['users', 'detail', 123]
  queryFn: () => fetchUser(userId),
})

// Invalidate all user queries after a bulk action
queryClient.invalidateQueries({ queryKey: userKeys.all })

// Invalidate only a specific user's detail
queryClient.invalidateQueries({ queryKey: userKeys.detail(42) })
Common Pitfall: Over-fetching

Don't put useQuery inside conditionals or loops — it violates the Rules of Hooks and causes issues. For conditional fetching, use the enabled option. For dynamic numbers of queries, use useQueries.

QueryClient Configuration Reference

Option Default Recommendation
staleTime 0 (always stale) Set to 1–5 min for most resources
gcTime 5 minutes Leave default; increase for rarely changing data
retry 3 Lower to 1–2 for user-triggered actions
refetchOnWindowFocus true Keep true for real-time-ish data; disable for static
refetchOnReconnect true Keep true — users expect fresh data after reconnect
refetchInterval false Set per-query for polling (e.g., live dashboards)

Conclusion

TanStack Query doesn't replace your global state manager — it eliminates the need for one when it comes to server data. By letting the library handle caching, background refetching, deduplication, and error states, you get to write significantly less code while delivering a better user experience.

The key mental shift is recognising that your cache is your server state. You don't need to setIsLoading(true), manually clear stale data, or coordinate async updates across components. TanStack Query handles all of that, and the DevTools make it transparent.

Key Takeaways

  • Separate server state from client state — TanStack Query owns the former, useState/Zustand owns the latter
  • Use the query key factory pattern to centralise cache keys and simplify invalidation
  • Set a realistic staleTime — the default of 0 causes more network requests than you need
  • Wrap useQuery in custom hooks — co-locate the key, fetcher, and options in one reusable place
  • Optimistic updates + onError rollback make mutations feel instant without sacrificing data integrity
  • Prefetch on hover for navigation links — pages load instantly when the user clicks
TanStack Query React Data Fetching JavaScript State Management
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.