React Query JSON Fetching: useQuery, useMutation & Cache Strategies

Last updated:

React Query (TanStack Query v5) manages JSON fetching, caching, and synchronization for server state — returning structured { data, error, isPending } objects that re-render components only when JSON content changes. The default staleTime of 0 ms means every window focus triggers a background refetch; setting staleTime to 60,000 ms reduces network requests by up to 90% for stable JSON APIs. This guide covers useQuery and useMutation with JSON APIs, cache configuration, select transforms, optimistic updates, infinite queries for paginated JSON, and parallel/dependent query patterns.

useQuery Basics: Fetching JSON with Query Keys

useQuery is the primary hook for fetching JSON from a server. It takes a queryKey array that uniquely identifies the cached data and a queryFn that returns a Promise resolving to JSON. React Query deduplicates concurrent requests with the same queryKey — ten components mounting simultaneously with queryKey: ['users'] trigger a single HTTP request. The hook returns { data, error, isPending, isError, isFetching }; isPending is true only on the first load when no cache exists, while isFetching is true during any background refetch. The enabled flag suppresses the query entirely when set to false — useful for conditional fetching. refetchOnWindowFocus defaults to true: every time the browser tab regains focus, React Query fires a background refetch if the data is stale, keeping JSON in sync without explicit polling.

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

// 1. Create a QueryClient (once, at app root)
const queryClient = new QueryClient()

// 2. Wrap your app
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <UserList />
    </QueryClientProvider>
  )
}

// 3. Fetch JSON with useQuery
interface User {
  id: number
  name: string
  email: string
}

async function fetchUsers(): Promise<User[]> {
  const res = await fetch('/api/users')
  // fetch does NOT throw on 4xx/5xx — throw manually
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
  return res.json() as Promise<User[]>
}

function UserList() {
  const { data, error, isPending, isFetching } = useQuery<User[]>({
    queryKey: ['users'],          // cache key — array, can include params
    queryFn: fetchUsers,          // returns Promise<User[]>
    staleTime: 60_000,            // treat cache as fresh for 60 s
    refetchOnWindowFocus: false,  // disable focus-triggered refetch
  })

  if (isPending) return <p>Loading users…</p>
  if (error)    return <p>Error: {error.message}</p>

  return (
    <>
      {isFetching && <p>Updating…</p>}
      <ul>
        {data.map(user => (
          <li key={user.id}>{user.name} — {user.email}</li>
        ))}
      </ul>
    </>
  )
}

// 4. Query key with parameters — separate cache entry per userId
function UserDetail({ userId }: { userId: number }) {
  const { data } = useQuery<User>({
    queryKey: ['users', userId],                    // unique per userId
    queryFn: () => fetch(`/api/users/${userId}`)
      .then(r => { if (!r.ok) throw new Error(r.statusText); return r.json() }),
    enabled: userId > 0,                            // skip when userId is 0
  })
  return <pre>{JSON.stringify(data, null, 2)}</pre>
}

Query keys should be serializable and deterministic — include all variables that the queryFn depends on. A query key of ['users', userId] creates a separate cache slot per user ID, so navigating between users shows the cached JSON instantly while a background refresh runs. Avoid objects with unpredictable key order in query keys; use arrays of primitives or stable JSON-serializable objects instead.

Cache Configuration: staleTime, gcTime, and Refetch Policies

React Query's caching model has two independent timers per query. staleTime controls freshness: during this window, cached JSON is served without any background network request — not even on window focus. gcTime controls memory retention: after all components using a query unmount, React Query keeps the JSON in memory for gcTime (default 5 minutes) so a fast remount can serve cached data immediately. A query can be stale but still in cache — React Query will serve the cached JSON instantly and fire a background refetch in parallel. refetchInterval enables polling: set it to a millisecond value to automatically refetch JSON on a timer. refetchOnReconnect (default true) refetches when the browser regains network connectivity. structuralSharing (default true) deep-compares the new JSON response to the cached value and preserves object references that did not change, preventing unnecessary re-renders in child components. placeholderData allows displaying stale or skeleton data before the first fetch completes.

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

// staleTime vs gcTime
const { data } = useQuery({
  queryKey: ['config'],
  queryFn: () => fetch('/api/config').then(r => r.json()),

  // staleTime: serve cache without refetch for 5 minutes
  staleTime: 5 * 60 * 1000,

  // gcTime: keep unused JSON in memory for 10 minutes
  gcTime: 10 * 60 * 1000,
})

// Polling: refetch JSON every 10 seconds
const { data: liveStats } = useQuery({
  queryKey: ['stats'],
  queryFn: () => fetch('/api/stats').then(r => r.json()),
  refetchInterval: 10_000,              // poll every 10 s
  refetchIntervalInBackground: true,    // continue polling when tab is hidden
})

// placeholderData: show previous page JSON while fetching next page
// (common in paginated tables — avoids empty flash)
const [page, setPage] = React.useState(1)
const { data: pageData } = useQuery({
  queryKey: ['products', page],
  queryFn: () => fetch(`/api/products?page=${page}`).then(r => r.json()),
  placeholderData: keepPreviousData,    // v5 helper — keeps last page JSON visible
  staleTime: 30_000,
})

// Global defaults — apply to all queries in the app
import { QueryClient } from '@tanstack/react-query'
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60_000,          // 1 minute default staleTime
      gcTime:    5 * 60 * 1000,   // 5 minute default gcTime
      retry: 2,                   // retry failed JSON fetches twice
      refetchOnWindowFocus: true, // keep JSON fresh on tab focus
    },
  },
})

For most REST JSON APIs, a staleTime of 30–60 seconds is a practical default that eliminates redundant refetches without serving significantly outdated data. For immutable or rarely-changing JSON (e.g., configuration endpoints), use staleTime: Infinity and rely on explicit invalidateQueries calls after writes to control cache freshness.

useMutation and JSON POST/PUT/DELETE

useMutation handles JSON write operations — POST, PUT, PATCH, and DELETE. Unlike useQuery, mutations are not automatically triggered on mount; you call mutate(variables) or mutateAsync(variables) imperatively. The hook exposes { mutate, mutateAsync, isPending, isError, error, data }. The variables argument passed to mutate flows into the mutationFn and into the onSuccess, onError, and onSettled callbacks. Use mutateAsync when you need to await the mutation inside an async function and handle errors with try/catch. Failed mutations retry 0 times by default (unlike queries which retry 3 times); set retry: 3 explicitly for idempotent operations like JSON PATCH requests.

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

interface CreateUserInput {
  name: string
  email: string
}
interface User extends CreateUserInput {
  id: number
}

async function createUser(input: CreateUserInput): Promise<User> {
  const res = await fetch('/api/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(input),
  })
  if (!res.ok) throw new Error(`Create failed: ${res.status}`)
  return res.json()
}

function CreateUserForm() {
  const queryClient = useQueryClient()

  const mutation = useMutation<User, Error, CreateUserInput>({
    mutationFn: createUser,
    onSuccess: (newUser, variables) => {
      // Invalidate users list so next render refetches updated JSON
      queryClient.invalidateQueries({ queryKey: ['users'] })
      console.log('Created:', newUser.id, variables.name)
    },
    onError: (error, variables) => {
      console.error('Failed to create user:', error.message, variables)
    },
    onSettled: () => {
      // Runs after success or error — good for cleanup
    },
  })

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const form = e.currentTarget
    mutation.mutate({
      name: (form.elements.namedItem('name') as HTMLInputElement).value,
      email: (form.elements.namedItem('email') as HTMLInputElement).value,
    })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" placeholder="Name" required />
      <input name="email" type="email" placeholder="Email" required />
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? 'Creating…' : 'Create User'}
      </button>
      {mutation.isError && <p>Error: {mutation.error.message}</p>}
      {mutation.isSuccess && <p>Created user #{mutation.data.id}</p>}
    </form>
  )
}

// mutateAsync: await inside async handler, catch errors manually
async function handleBulkCreate(users: CreateUserInput[]) {
  try {
    const results = await Promise.all(users.map(u => mutation.mutateAsync(u)))
    console.log('Bulk created:', results.length)
  } catch (err) {
    console.error('Bulk create failed:', err)
  }
}

The three lifecycle callbacks — onSuccess, onError, and onSettled — can be defined both on the useMutation hook and at the mutate/mutateAsync call site. Hook-level callbacks run before call-site callbacks. This lets you define global cache invalidation logic in the hook while handling component-specific UI feedback at the call site.

Optimistic Updates with JSON Cache

Optimistic updates apply a JSON cache change immediately — before the server responds — so the UI reflects the intended state without any loading delay. The pattern uses onMutate to snapshot and update the cache, onError to roll back on failure, and onSettled to invalidate and sync with the real server JSON. Calling queryClient.cancelQueries inside onMutate is critical: it aborts any in-flight refetch that could overwrite the optimistic data with stale JSON before the mutation completes. setQueryData writes directly to the in-memory JSON cache without triggering a network request, producing instant UI feedback.

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

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

async function toggleTodo(todo: Todo): Promise<Todo> {
  const res = await fetch(`/api/todos/${todo.id}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ completed: !todo.completed }),
  })
  if (!res.ok) throw new Error('Toggle failed')
  return res.json()
}

function useTodoToggle() {
  const queryClient = useQueryClient()

  return useMutation<Todo, Error, Todo>({
    mutationFn: toggleTodo,

    onMutate: async (optimisticTodo) => {
      // Step 1: Cancel any outgoing refetches to avoid overwriting optimistic data
      await queryClient.cancelQueries({ queryKey: ['todos'] })

      // Step 2: Snapshot current JSON cache for rollback
      const previousTodos = queryClient.getQueryData<Todo[]>(['todos'])

      // Step 3: Apply optimistic update — flip completed immediately
      queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
        old.map(t =>
          t.id === optimisticTodo.id
            ? { ...t, completed: !t.completed }
            : t
        )
      )

      // Step 4: Return snapshot so onError can roll back
      return { previousTodos }
    },

    onError: (_err, _todo, context) => {
      // Roll back to the snapshot on failure
      if (context?.previousTodos) {
        queryClient.setQueryData(['todos'], context.previousTodos)
      }
    },

    onSettled: () => {
      // Always re-sync JSON cache with server after success or error
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    },
  })
}

function TodoItem({ todo }: { todo: Todo }) {
  const toggle = useTodoToggle()
  return (
    <li
      style={{ textDecoration: todo.completed ? 'line-through' : 'none', cursor: 'pointer' }}
      onClick={() => toggle.mutate(todo)}
    >
      {todo.title}
    </li>
  )
}

The optimistic pattern is most impactful for toggle operations and simple field updates where the expected server response is deterministic. For complex mutations where the server may transform the data significantly (e.g., assigning a new ID or computing derived fields), prefer invalidation-only updates and rely on the server JSON response to set the final state rather than attempting to predict it optimistically.

Select Transforms and JSON Shape Normalization

The select option transforms raw JSON API responses into the exact shape your component needs, without affecting the cached data. React Query caches the original JSON from queryFn and re-runs select only when the underlying data changes (using structural sharing). This separation lets multiple components share one cached JSON fetch while each consuming a different derived shape. Wrapping select in useCallback stabilizes the function reference across renders, enabling React Query's memoization to prevent re-renders when the source JSON is unchanged. The select function is also the correct place to normalize inconsistent API JSON shapes — flattening nested objects, renaming keys, or computing derived fields — keeping that logic out of render functions and reducers.

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

// Raw API JSON shape (server response)
interface ApiUser {
  user_id: number
  first_name: string
  last_name: string
  email_address: string
  created_at: string
  is_active: boolean
}

// UI shape (what components need)
interface DisplayUser {
  id: number
  fullName: string
  email: string
  joinedYear: number
}

async function fetchApiUsers(): Promise<ApiUser[]> {
  const res = await fetch('/api/users')
  if (!res.ok) throw new Error(res.statusText)
  return res.json()
}

// Component A: needs full display users
function UserTable() {
  const selectDisplayUsers = useCallback(
    (data: ApiUser[]): DisplayUser[] =>
      data
        .filter(u => u.is_active)
        .map(u => ({
          id: u.user_id,
          fullName: `${u.first_name} ${u.last_name}`,
          email: u.email_address,
          joinedYear: new Date(u.created_at).getFullYear(),
        })),
    []  // stable reference — no dependencies
  )

  const { data: users = [] } = useQuery({
    queryKey: ['users'],
    queryFn: fetchApiUsers,
    select: selectDisplayUsers,    // transforms ApiUser[] → DisplayUser[]
    staleTime: 60_000,
  })

  return (
    <table>
      <tbody>
        {users.map(u => (
          <tr key={u.id}>
            <td>{u.fullName}</td>
            <td>{u.email}</td>
            <td>{u.joinedYear}</td>
          </tr>
        ))}
      </tbody>
    </table>
  )
}

// Component B: only needs email list — same cached JSON, different select
function EmailList() {
  const { data: emails = [] } = useQuery({
    queryKey: ['users'],    // same key — reuses cache, no extra HTTP request
    queryFn: fetchApiUsers,
    select: useCallback(
      (data: ApiUser[]) => data.map(u => u.email_address),
      []
    ),
  })
  return <ul>{emails.map(e => <li key={e}>{e}</li>)}</ul>
}

// select for a single item: derive from list cache (avoids extra request)
function UserBadge({ userId }: { userId: number }) {
  const { data: user } = useQuery({
    queryKey: ['users'],
    queryFn: fetchApiUsers,
    select: useCallback(
      (data: ApiUser[]) => data.find(u => u.user_id === userId),
      [userId]
    ),
  })
  return user ? <span>{user.first_name}</span> : null
}

Using select for normalization is preferable to transforming data in useEffect or component render, because React Query tracks the transformed result separately — a component only re-renders when its specific select output changes, even if the full JSON cache was updated with unrelated fields.

Infinite Queries for Paginated JSON APIs

useInfiniteQuery handles paginated JSON APIs where each response contains a page of items and a token pointing to the next page. It stores all fetched pages in a pages array within the cache, appending new pages without discarding previous ones. You provide initialPageParam (the first page identifier) and getNextPageParam (a function that extracts the next page token from the last loaded page's JSON). Calling fetchNextPage() triggers a new request with the next page token as pageParam. The hasNextPage boolean is true when getNextPageParam returns a non-undefined value. Use select to flatten the nested pages[].items arrays into a single array for rendering, avoiding flatMap in JSX.

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

interface Post { id: number; title: string; body: string }
interface PostsPage {
  items: Post[]
  nextCursor: string | null   // cursor-based pagination
  total: number
}

async function fetchPosts({ pageParam }: { pageParam: string | null }): Promise<PostsPage> {
  const url = new URL('/api/posts', window.location.origin)
  if (pageParam) url.searchParams.set('cursor', pageParam)
  url.searchParams.set('limit', '20')
  const res = await fetch(url)
  if (!res.ok) throw new Error(res.statusText)
  return res.json()
}

function PostFeed() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isPending,
    error,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    initialPageParam: null,             // first request has no cursor
    getNextPageParam: (lastPage) =>
      lastPage.nextCursor ?? undefined, // undefined = no more pages

    // Flatten all pages into a single Post[] for rendering
    select: useCallback(
      (data: { pages: PostsPage[]; pageParams: (string | null)[] }) =>
        data.pages.flatMap(p => p.items),
      []
    ),
  })

  // Infinite scroll: trigger fetchNextPage when sentinel enters viewport
  const sentinelRef = useRef<HTMLDivElement>(null)
  React.useEffect(() => {
    const el = sentinelRef.current
    if (!el) return
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) {
        fetchNextPage()
      }
    })
    observer.observe(el)
    return () => observer.disconnect()
  }, [hasNextPage, isFetchingNextPage, fetchNextPage])

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

  return (
    <div>
      {(data ?? []).map(post => (
        <article key={post.id}>
          <h3>{post.title}</h3>
          <p>{post.body}</p>
        </article>
      ))}
      <div ref={sentinelRef} style={{ height: 1 }} />
      {isFetchingNextPage && <p>Loading more…</p>}
      {!hasNextPage && <p>All posts loaded.</p>}
    </div>
  )
}

// Offset-based pagination: pageParam is a page number
const offsetQuery = useInfiniteQuery({
  queryKey: ['items', 'offset'],
  queryFn: ({ pageParam }: { pageParam: number }) =>
    fetch(`/api/items?page=${pageParam}&size=25`).then(r => r.json()),
  initialPageParam: 1,
  getNextPageParam: (lastPage: { page: number; totalPages: number }) =>
    lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
})

React Query caches each page of JSON separately — navigating away and returning to a feed page restores the previously loaded pages instantly from cache (subject to staleTime), rather than refetching from page 1. Use maxPages to cap the number of pages kept in memory for very long feeds, preventing unbounded memory growth.

Parallel and Dependent Queries

React Query handles parallel JSON fetches natively — multiple useQuery calls in the same component fire simultaneously without any special configuration. For a dynamic list of parallel queries (e.g., fetching JSON for each item in an array), use useQueries which accepts an array of query options and returns an array of results. Dependent queries — where one query's parameters come from a previous query's JSON data — use the enabled flag to defer the second query until the first resolves. Suspense mode with useSuspenseQuery integrates React's Suspense and error boundary system: the component suspends while JSON is loading and the nearest <Suspense> boundary shows the fallback, simplifying loading state management at the cost of requiring proper error boundaries around every suspended subtree.

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

// ── Parallel queries: two independent JSON fetches, simultaneous ──
function Dashboard({ userId }: { userId: number }) {
  // Both fire at the same time — no waterfall
  const userQuery   = useQuery({ queryKey: ['user', userId],   queryFn: () => fetchUser(userId)   })
  const ordersQuery = useQuery({ queryKey: ['orders', userId], queryFn: () => fetchOrders(userId) })

  if (userQuery.isPending || ordersQuery.isPending) return <p>Loading…</p>

  return (
    <div>
      <h2>{userQuery.data?.name}</h2>
      <p>Orders: {ordersQuery.data?.length}</p>
    </div>
  )
}

// ── useQueries: dynamic list of parallel JSON fetches ──
function PostComments({ postIds }: { postIds: number[] }) {
  const commentQueries = useQueries({
    queries: postIds.map(id => ({
      queryKey: ['comments', id],
      queryFn: () => fetch(`/api/posts/${id}/comments`).then(r => r.json()),
      staleTime: 60_000,
    })),
  })

  const isLoading = commentQueries.some(q => q.isPending)
  if (isLoading) return <p>Loading comments…</p>

  return (
    <ul>
      {commentQueries.map((q, i) => (
        <li key={postIds[i]}>Post {postIds[i]}: {q.data?.length ?? 0} comments</li>
      ))}
    </ul>
  )
}

// ── Dependent queries: second query waits for first JSON ──
function UserOrders({ userId }: { userId: number }) {
  // Step 1: fetch user JSON
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  })

  // Step 2: only fires when user.accountId is available
  const { data: orders } = useQuery({
    queryKey: ['orders', user?.accountId],
    queryFn: () => fetchOrdersByAccount(user!.accountId),
    enabled: !!user?.accountId,  // deferred until Step 1 resolves
  })

  return <p>Orders: {orders?.length ?? '…'}</p>
}

// ── Suspense mode: useSuspenseQuery ──
// Component suspends while JSON loads; wrap with <Suspense> and <ErrorBoundary>
function SuspenseUserProfile({ userId }: { userId: number }) {
  // No isPending check needed — component suspends instead
  const { data: user } = useSuspenseQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  })

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

// Usage:
// <ErrorBoundary fallback={<p>Error loading user</p>}>
//   <Suspense fallback={<p>Loading user…</p>}>
//     <SuspenseUserProfile userId={1} />
//   </Suspense>
// </ErrorBoundary>

useQueries is preferred over a loop of useQuery calls because it is a single hook call — React's rules of hooks prohibit conditional or dynamic hook calls in loops. When combining parallel results, check queries.every(q => q.isSuccess) before accessing q.data to avoid undefined access errors. For suspense mode, useSuspenseQuery guarantees that data is always defined when the component renders, eliminating the if (isPending) guard entirely.

Key Terms

Query key
A serializable array that uniquely identifies a cached JSON entry in the QueryClient. React Query uses deep equality on the query key to look up, deduplicate, and invalidate cache entries. A key of ['users', 1] is a different cache slot from ['users', 2]. All variables that the queryFn depends on must appear in the query key — otherwise the cache will not update when those variables change. Keys should contain only primitives or plain objects; avoid including non-serializable values like functions or class instances.
Stale data
A cached JSON entry is stale when its age exceeds staleTime. Stale data is still served immediately from cache on component mount or query re-use, but React Query fires a background refetch to update it. The default staleTime of 0 ms means data is stale the instant it is cached, triggering a background refetch on every window focus, component mount, and network reconnect. Setting a higher staleTime reduces background refetch frequency, which is critical for mobile users and high-traffic applications where reducing unnecessary JSON fetches matters for performance and cost.
gc time
Garbage collection time (gcTime, formerly cacheTime in v4) is the duration React Query retains a query's JSON in memory after all subscribers (components using that query) unmount. The default is 5 minutes. During this window, if a component remounts with the same query key, it receives the cached JSON instantly (possibly triggering a background refetch if stale). After gcTime elapses with no subscribers, React Query removes the entry from memory. Setting gcTime: Infinity keeps JSON in memory for the lifetime of the QueryClient instance — useful for reference data that never changes.
Query function
The queryFn is an async function that fetches and returns JSON data. It receives a context object containing queryKey, signal (an AbortSignal for cancellation), and pageParam (for infinite queries). React Query calls queryFn when data is stale or missing and at least one subscriber is active. The function must return a Promise that resolves to JSON-serializable data or throws an error on failure. Pass the signal to fetch to enable automatic request cancellation when a component unmounts or the query key changes before the response arrives.
Structural sharing
React Query's structural sharing algorithm deep-compares a new JSON response to the previously cached value. Object references that are deeply equal are preserved from the old cache entry rather than replaced with new objects. This means components that depend on a specific nested JSON object only re-render if that object's content actually changed — not merely because a refetch returned a new JavaScript object with identical values. Structural sharing is enabled by default and is responsible for much of React Query's render optimization. It can be disabled with structuralSharing: false for JSON that contains non-serializable values or for performance debugging.
Query invalidation
Invalidation marks one or more cached JSON entries as stale and triggers an immediate background refetch for any active subscribers. Call queryClient.invalidateQueries({ queryKey: ['users'] }) to invalidate all queries whose key starts with 'users'. Invalidation is the primary mechanism for keeping JSON caches consistent after mutations — after a POST or DELETE, invalidate the affected list queries so components show the updated data. Unlike refetchQueries, invalidation only triggers a network request if a subscriber is currently active; if no component is mounted for that query, the refetch is deferred until the next subscriber mounts.

FAQ

What is the difference between staleTime and gcTime in React Query?

staleTime controls how long a cached JSON response is considered fresh. During this window, React Query serves the cached data immediately without triggering a background refetch — even on window focus or component remount. The default staleTime is 0 ms, meaning data is immediately stale after the first fetch. gcTime (garbage collection time, formerly cacheTime) controls how long unused query data stays in memory after all subscribers unmount. The default gcTime is 5 minutes. A query can be stale but still in cache: when a component remounts, React Query serves the stale cache entry immediately and fires a background refetch to update it. Setting staleTime: Infinity disables background refetches entirely, making the cache permanent until explicitly invalidated.

How do I fetch JSON from a REST API with useQuery?

Wrap your fetch call in a queryFn and assign a unique queryKey array. The queryFn must return a Promise that resolves to JSON-serializable data. React Query calls it automatically and exposes { data, error, isPending } for rendering. Example: const { data } = useQuery({ queryKey: ['users'], queryFn: () => fetch('/api/users').then(res => { if (!res.ok) throw new Error(res.statusText); return res.json(); }) }). Always throw an error for non-2xx responses — fetch does not throw on HTTP errors by default. For TypeScript, generic the call: useQuery<User[]>({ ... }) to type the data field.

How does React Query handle JSON parsing errors?

React Query catches any error thrown inside queryFn and places it on the error field of the query result. If fetch returns a non-JSON body and you call res.json(), the resulting SyntaxError is caught and surfaced as error. React Query retries failed queries automatically — by default 3 times with exponential backoff — before setting status to 'error'. To disable retries for parse errors specifically, set retry: (failureCount, error) => !(error instanceof SyntaxError). The isPending flag remains false once an error is set; check isError and error to render error states.

Can I use React Query with Next.js Server Components?

React Query hooks require a React context and component state — they only work in Client Components. For Server Components, use fetch directly with Next.js cache options, or use the experimental hydration pattern: prefetch queries on the server using QueryClient.prefetchQuery, dehydrate the cache to JSON with dehydrate(queryClient), pass it to a HydrationBoundary in a Client Component, and call useQuery on the client with the same queryKey. The client hydrates from the server-provided JSON cache, skipping the first fetch entirely. See the JSON in Next.js guide for a full hydration walkthrough.

How do I invalidate and refetch JSON after a mutation?

Call queryClient.invalidateQueries({ queryKey: ['users'] }) inside the onSuccess callback of useMutation. Invalidation marks matching cache entries as stale and triggers an immediate background refetch for any active subscribers. For exact key matching, pass exact: true. For partial matching (invalidating all user-related queries), pass only the shared prefix: { queryKey: ['users'] } matches ['users'], ['users', 1], and ['users', 'list']. For optimistic updates, combine setQueryData for immediate UI update with invalidateQueries in onSettled to sync with the server JSON response.

What is the select option and when should I use it?

The select option is a transform function that runs after queryFn resolves, converting the raw JSON API response into the shape your component needs. The transformed result is what data exposes — the original JSON stays cached and the transform re-runs only when the source data changes. Use select to extract a subset of fields, normalize nested JSON to a flat shape, sort or filter arrays without side effects, or compute derived values. Wrap the select function in useCallback to prevent unnecessary re-renders — React Query memoizes the select result by reference when the underlying JSON data has not changed.

How do I implement optimistic UI updates with JSON mutations?

Use the onMutate callback to apply an optimistic JSON update before the server responds. The pattern: (1) cancel in-flight queries with queryClient.cancelQueries to prevent race conditions; (2) snapshot the previous JSON cache with queryClient.getQueryData; (3) apply the optimistic update with queryClient.setQueryData; (4) return the snapshot from onMutate. In onError, call queryClient.setQueryData with the snapshot to roll back. In onSettled, call queryClient.invalidateQueries to sync with the real server JSON. This reduces perceived mutation latency by 200–400 ms because the UI updates immediately without waiting for the round-trip.

How do I paginate JSON API responses with useInfiniteQuery?

Use useInfiniteQuery instead of useQuery for paginated JSON APIs. Provide an initialPageParam (e.g. 1 or a cursor string) and a getNextPageParam function that extracts the next page token from the last page's JSON response. React Query calls queryFn with a pageParam argument for each page. To load more, call fetchNextPage(). The data object contains a pages array (each element is one page's JSON). Flatten items with select: data => data.pages.flatMap(p => p.items) to get a single array for rendering. Check hasNextPageto conditionally render a "Load more" button or trigger infinite scroll.

Further reading and primary sources