ReactTanStack Query (React Query)

TanStack Query (React Query)

TanStack Query (formerly React Query) is the industry-standard library for managing server state in React applications. It solves the entire surface area of data fetching — loading states, error states, caching, background refetching, pagination, mutations, and synchronisation — so you don't have to write it yourself. If you've ever built the manual useEffect + fetch pattern, TanStack Query replaces hundreds of lines of that boilerplate with a few hook calls.

Installation & Setup

Bash
npm install @tanstack/react-query

Wrap your application with QueryClientProvider to give every component access to the query cache:

JSX
// main.jsx (or _app.tsx in Next.js Pages Router)
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,   // data is fresh for 5 minutes
      retry: 2,                    // retry failed requests twice
    },
  },
})

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
    </QueryClientProvider>
  )
}
Note
Create the QueryClient outside the component tree so it is not recreated on every render. One QueryClient per application is the standard pattern.
useQuery: Fetching Data

useQuery is the primary hook. It takes an object with a queryKey (cache identifier) and a queryFn (the async function that fetches the data):

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

async function fetchPosts() {
  const res = await fetch('https://jsonplaceholder.typicode.com/posts')
  if (!res.ok) throw new Error('Network response was not ok')
  return res.json()
}

function PostList() {
  const {
    data: posts,
    isLoading,
    isError,
    error,
    isFetching,        // true when a background refetch is in progress
    refetch,           // manually trigger a refetch
  } = useQuery({
    queryKey: ['posts'],   // unique key for this query's cache entry
    queryFn: fetchPosts,
  })

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

  return (
    <div>
      {isFetching && <span>Refreshing…</span>}
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
      <button onClick={() => refetch()}>Refresh</button>
    </div>
  )
}
Query Keys: The Cache Identity

The queryKey is how TanStack Query identifies entries in its cache. Keys are serialised arrays — include any variables the query depends on:

JSX
// Static query — same data every time
useQuery({ queryKey: ['posts'], queryFn: fetchPosts })

// Dynamic query — different data for each userId
useQuery({
  queryKey: ['users', userId],    // ['users', 1], ['users', 2] etc.
  queryFn: () => fetchUser(userId),
  enabled: !!userId,              // don't run if userId is falsy
})

// Nested data — comments for a specific post
useQuery({
  queryKey: ['posts', postId, 'comments'],
  queryFn: () => fetchComments(postId),
})
useMutation: Creating, Updating, Deleting

For operations that change server state (POST, PUT, PATCH, DELETE), use useMutation. After a successful mutation, invalidate the relevant queries so the cache refreshes automatically:

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

async function createPost(newPost) {
  const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(newPost),
  })
  if (!res.ok) throw new Error('Failed to create post')
  return res.json()
}

function CreatePostForm() {
  const queryClient = useQueryClient()

  const mutation = useMutation({
    mutationFn: createPost,
    onSuccess: () => {
      // Invalidate the posts cache → triggers a background refetch
      queryClient.invalidateQueries({ queryKey: ['posts'] })
    },
    onError: (err) => {
      console.error('Mutation failed:', err.message)
    },
  })

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

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" placeholder="Post title" required />
      <textarea name="body" placeholder="Post body" />
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? 'Creating…' : 'Create Post'}
      </button>
      {mutation.isError && <p>Error: {mutation.error.message}</p>}
      {mutation.isSuccess && <p>Post created!</p>}
    </form>
  )
}
Stale-While-Revalidate & Background Refetching

TanStack Query implements a stale-while-revalidate strategy: it shows cached (possibly stale) data immediately, then fetches fresh data in the background and updates the UI when the new data arrives. This makes your app feel instant.

  • staleTime — how long data is considered fresh. During this period, no refetch happens even if the query is re-mounted. Default: 0 (always stale).

  • gcTime (formerly cacheTime) — how long inactive cached data is kept in memory. Default: 5 minutes.

  • refetchOnWindowFocus — automatically refetch when the user returns to the tab. Default: true.

  • refetchInterval — poll the server on a fixed interval (e.g. every 30 seconds for a live feed).

JSX
useQuery({
  queryKey: ['stockPrice', symbol],
  queryFn: () => fetchStockPrice(symbol),
  staleTime: 1000 * 30,        // fresh for 30 seconds
  refetchInterval: 1000 * 30,  // poll every 30 seconds
  refetchOnWindowFocus: true,
})
Pagination with useInfiniteQuery

useInfiniteQuery handles infinite scroll and load-more pagination patterns:

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

function InfinitePosts() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  } = useInfiniteQuery({
    queryKey: ['posts', 'infinite'],
    queryFn: ({ pageParam = 1 }) =>
      fetch(`/api/posts?page=${pageParam}&limit=10`)
        .then(r => r.json()),
    getNextPageParam: (lastPage, allPages) =>
      lastPage.hasMore ? allPages.length + 1 : undefined,
  })

  if (isLoading) return <p>Loading…</p>

  return (
    <div>
      {data.pages.map((page, i) => (
        <div key={i}>
          {page.posts.map(post => (
            <p key={post.id}>{post.title}</p>
          ))}
        </div>
      ))}

      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage ? 'Loading more…' : 'Load more'}
      </button>
    </div>
  )
}
Complete Posts CRUD Example

JSX
// postsApi.js
export const fetchPosts  = () => fetch('/api/posts').then(r => r.json())
export const fetchPost   = (id) => fetch(`/api/posts/${id}`).then(r => r.json())
export const createPost  = (body) =>
  fetch('/api/posts', { method: 'POST', body: JSON.stringify(body) }).then(r => r.json())
export const updatePost  = ({ id, ...body }) =>
  fetch(`/api/posts/${id}`, { method: 'PUT', body: JSON.stringify(body) }).then(r => r.json())
export const deletePost  = (id) =>
  fetch(`/api/posts/${id}`, { method: 'DELETE' })

// PostsPage.jsx
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { fetchPosts, deletePost } from './postsApi'

function PostsPage() {
  const queryClient = useQueryClient()
  const { data: posts = [], isLoading } = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  })

  const deleteMutation = useMutation({
    mutationFn: deletePost,
    onSuccess: (_, deletedId) => {
      // Optimistic: remove from cache immediately without waiting for refetch
      queryClient.setQueryData(['posts'], old =>
        old.filter(p => p.id !== deletedId)
      )
    },
  })

  if (isLoading) return <p>Loading…</p>

  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>
          {post.title}
          <button
            onClick={() => deleteMutation.mutate(post.id)}
            disabled={deleteMutation.isPending}
          >
            Delete
          </button>
        </li>
      ))}
    </ul>
  )
}

Feature

Manual useEffect

TanStack Query

Loading state

Manual useState

isLoading / isPending

Error handling

Manual try/catch

isError / error

Caching

None

Automatic, configurable

Deduplication

None

Same key = one request

Background refetch

Manual

Automatic on focus/interval

Optimistic updates

Complex manual code

setQueryData

Pagination

Manual state management

useInfiniteQuery

DevTools

None

React Query DevTools

Tip
Install @tanstack/react-query-devtools in development to get a floating panel showing all active queries, their state, data, and cache timing. It's invaluable for debugging.