Skip to content

Async State & React Query Interview Questions & Answers

20 questions Updated 2026-06-24 Share:

React Query interview questions — useQuery, useMutation, query keys, caching, stale time, refetching, optimistic updates, and async state management patterns.

Read the in-depth guideAsync State & React Query — Complete React Interview Guide(opens in new tab)
20 of 20

TanStack Query (formerly React Query) is a library for managing server state in React apps. It solves the boilerplate problem of using useState + useEffect to fetch data: no more manual loading flags, error states, cache invalidation, or race-condition handling.

// Without React Query — manual state machine
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)

useEffect(() => {
  setLoading(true)
  fetch('/api/user')
    .then(r => r.json())
    .then(d => { setData(d); setLoading(false) })
    .catch(e => { setError(e); setLoading(false) })
}, [])

// With React Query — three lines replace the whole block
const { data, isLoading, error } = useQuery({
  queryKey: ['user'],
  queryFn: () => fetch('/api/user').then(r => r.json()),
})

React Query also handles caching, background refetching, deduplication of concurrent requests, and retry logic — all out of the box.

Rule of thumb: if the data lives on a server and you want it fresh in the UI, React Query is the right tool; it is not for ephemeral UI state like modal toggles.

useQuery needs at minimum a queryKey and a queryFn. The third common option is staleTime.

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

function UserProfile({ userId }) {
  const { data, isLoading, isError, error } = useQuery({
    queryKey: ['user', userId],      // cache key — array form
    queryFn: () =>
      fetch(`/api/users/${userId}`).then(r => r.json()), // async fetcher
    staleTime: 1000 * 60 * 5,        // treat data as fresh for 5 min
  })

  if (isLoading) return <Spinner />
  if (isError)   return <p>{error.message}</p>
  return <h1>{data.name}</h1>
}

The hook returns many flags — isPending, isFetching, isSuccess, isError, isStale — plus data, error, and refetch. isLoading is true only on the very first fetch (no cached data + fetching); use isFetching to show a background-refetch spinner.

Rule of thumb: always destructure only what you need; the full return object has ~25 properties.

Query keys are the primary key for the query cache. Every unique key maps to its own cache entry. React Query uses them to deduplicate in-flight requests, invalidate targeted entries after mutations, and re-run queries when the key changes.

// Different keys → different cache entries
useQuery({ queryKey: ['user', 1], queryFn: ... }) // cache: user/1
useQuery({ queryKey: ['user', 2], queryFn: ... }) // cache: user/2

// Key changes when userId changes → automatic re-fetch
useQuery({ queryKey: ['user', userId], queryFn: fetchUser })

// Invalidate all 'user' queries after an update
queryClient.invalidateQueries({ queryKey: ['user'] })

Keys are serialized deeply (arrays and objects both work), so ['users', { status: 'active' }] and ['users', { status: 'inactive' }] are independent cache slots. Treat keys like a URL: they should uniquely and fully describe the data being fetched.

Rule of thumb: always include every variable the query depends on (IDs, filters, page numbers) in the key — never leave a dependency out.

staleTime controls how long fetched data is considered fresh. During that window React Query will serve cached data without re-fetching. Default is 0 (immediately stale).

gcTime (formerly cacheTime) controls how long unused cached data is kept in memory before garbage collection. Default is 5 minutes.

useQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
  staleTime: 1000 * 60,      // data is fresh for 1 min
  gcTime: 1000 * 60 * 10,    // cache kept 10 min after last observer
})
// 0–60 s:  component mounts → serves cache, no network request
// 60+ s:   data is stale → re-fetch in background on next mount/focus
// unmount: cache kept 10 min then collected

A high staleTime with a long gcTime means near-instant navigation between pages without spinners, while the data stays reasonably fresh.

Rule of thumb: staleTime = "how often can I tolerate stale data"; gcTime = "how long should I cache data after nobody is watching it."

React Query triggers a background refetch — silently updating stale data without showing a spinner — in four situations by default:

useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  // all true by default:
  refetchOnWindowFocus: true,     // tab gets focus again
  refetchOnMount: true,           // new component instance mounts
  refetchOnReconnect: true,       // network comes back online
  staleTime: 0,                   // data is immediately stale
})

During a background refetch isFetching is true but isLoading remains false (cached data still displayed). This lets you show a subtle "refreshing" indicator without blanking the screen.

You can also trigger refetches manually via refetch() or via queryClient.invalidateQueries({ queryKey: ['todos'] }).

Rule of thumb: increase staleTime to reduce unnecessary network traffic; set refetchOnWindowFocus: false in forms or dashboards where a mid-edit refresh would confuse users.

useQuery returns boolean flags for every state in the request lifecycle. Use them to render the right UI slice.

const { data, isLoading, isError, error, isSuccess } = useQuery({
  queryKey: ['profile'],
  queryFn: fetchProfile,
})

// isLoading: true only on first fetch (no cached data)
if (isLoading) return <Skeleton />

// isError: true when queryFn threw or rejected
if (isError) return <Alert message={error.message} />

// isSuccess: true once data is available (even stale)
return <Profile user={data} />

For background refreshes where you want to keep showing old data while new data loads, check isFetching alongside isSuccess.

Rule of thumb: check isLoading for the empty-state skeleton and isError for the error boundary — never check !data as a loading proxy, because stale data can exist alongside a fresh fetch.

useMutation handles write operations (POST, PUT, DELETE). Unlike useQuery it does not run automatically — you call mutate() or mutateAsync() explicitly.

const mutation = useMutation({
  mutationFn: (newTodo) =>
    fetch('/api/todos', {
      method: 'POST',
      body: JSON.stringify(newTodo),
    }).then(r => r.json()),

  onSuccess: (data, variables, context) => {
    // data = server response, variables = what you passed to mutate()
    queryClient.invalidateQueries({ queryKey: ['todos'] })
  },
  onError: (error, variables, context) => {
    console.error('Mutation failed:', error.message)
  },
  onSettled: () => {
    // runs after onSuccess OR onError — good for cleanup
  },
})

// trigger it
mutation.mutate({ title: 'Buy milk' })

mutation.status cycles through idle → pending → success | error. Use mutation.isPending to disable the submit button.

Rule of thumb: always invalidate or update the relevant query cache in onSuccess so the UI stays in sync with the server.

Optimistic updates apply the expected change to the cache immediately — before the server responds — so the UI feels instant. If the mutation fails you roll back using the snapshot saved in onMutate.

const queryClient = useQueryClient()

const toggleTodo = useMutation({
  mutationFn: (todo) =>
    fetch(`/api/todos/${todo.id}`, { method: 'PATCH',
      body: JSON.stringify({ done: !todo.done }) }).then(r => r.json()),

  onMutate: async (todo) => {
    // 1. cancel any in-flight refetch (avoids overwriting our optimistic data)
    await queryClient.cancelQueries({ queryKey: ['todos'] })

    // 2. snapshot the previous value
    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 }  // returned as "context"
  },

  onError: (_err, _todo, context) => {
    // 4. roll back on failure
    queryClient.setQueryData(['todos'], context.previous)
  },

  onSettled: () => {
    // 5. always refetch to sync with server truth
    queryClient.invalidateQueries({ queryKey: ['todos'] })
  },
})

The pattern — cancel → snapshot → optimistic write → rollback on error → invalidate on settle — is the canonical React Query optimistic workflow.

Rule of thumb: use optimistic updates for high-frequency interactions (toggles, likes, reorder) where a 200–500 ms spinner would feel sluggish.

Query invalidation marks a cached query as stale so that React Query refetches it the next time an active observer is present (or immediately if one is currently mounted).

// After creating a new post, invalidate the list
const mutation = useMutation({
  mutationFn: createPost,
  onSuccess: () => {
    // exact: false (default) — invalidates 'posts' and all sub-keys
    queryClient.invalidateQueries({ queryKey: ['posts'] })

    // exact: true — invalidates only this exact key
    queryClient.invalidateQueries({ queryKey: ['posts', 'list'], exact: true })
  },
})

Invalidation is preferred over setQueryData for list-after-create because the server may add timestamps, IDs, or derived fields that the client can't predict. Re-fetching gets you the canonical truth.

Rule of thumb: invalidate for create/delete operations (server adds data you don't have); use setQueryData only for update operations where you already have the complete new object.

Use the enabled option. When enabled is false the query is suspended — it will not fetch until the expression becomes truthy.

function UserOrders({ userId }) {
  // Step 1: fetch the user
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  })

  // Step 2: fetch orders only once we have user.accountId
  const { data: orders } = useQuery({
    queryKey: ['orders', user?.accountId],
    queryFn: () => fetchOrders(user.accountId),
    enabled: !!user?.accountId,  // waits for accountId to exist
  })

  return <OrderList orders={orders} />
}

enabled accepts any expression — !!id, isSuccess, a feature flag — and React Query will start the query the moment the value becomes truthy.

Rule of thumb: always guard with !!value not just value, so that empty strings and 0 don't accidentally enable a query.

useInfiniteQuery manages a list of pages. Each page is fetched via queryFn which receives pageParam (the cursor/page number for that page). getNextPageParam derives the next cursor from the last page.

const {
  data,           // { pages: [...], pageParams: [...] }
  fetchNextPage,
  hasNextPage,
  isFetchingNextPage,
} = useInfiniteQuery({
  queryKey: ['posts'],
  queryFn: ({ pageParam = 0 }) =>
    fetch(`/api/posts?cursor=${pageParam}`).then(r => r.json()),

  getNextPageParam: (lastPage) =>
    lastPage.nextCursor ?? undefined, // undefined stops pagination

  initialPageParam: 0,               // v5 required option
})

// Flatten all pages for rendering
const posts = data?.pages.flatMap(p => p.items) ?? []

Call fetchNextPage() from an "Load more" button or an intersection observer at the bottom of the list.

Rule of thumb: useInfiniteQuery is for cursor-based or offset-based lists; for discrete page navigation (page 1 / 2 / 3 buttons) use a plain useQuery with the page number in the key.

Create a QueryClient instance once at app startup and wrap the tree with QueryClientProvider. Every hook inside the tree shares that client.

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

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60,      // global 1-min freshness
      retry: 2,                   // retry failed queries twice
    },
  },
})

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Router />
      <ReactQueryDevtools initialIsOpen={false} /> {/* dev-only panel */}
    </QueryClientProvider>
  )
}

QueryClient is created outside the component so it is not re-created on every render. In tests, create a fresh QueryClient per test to prevent shared state between cases.

Rule of thumb: never create the QueryClient inside a component — it would reset the cache on every render.

prefetchQuery populates the cache proactively — before the component that needs the data has mounted. This eliminates the loading state for routes the user is likely to visit next.

const queryClient = useQueryClient()

// On mouse-enter of a "User Profile" link
function UserLink({ userId }) {
  const handleMouseEnter = () => {
    queryClient.prefetchQuery({
      queryKey: ['user', userId],
      queryFn: () => fetchUser(userId),
      staleTime: 1000 * 60 * 5,  // don't prefetch if cached and fresh
    })
  }
  return <Link to={`/users/${userId}`} onMouseEnter={handleMouseEnter}>...</Link>
}

You can also call prefetchQuery in a route loader (React Router v6+, Next.js getServerSideProps, TanStack Router) to fetch data on the server before the page renders.

Rule of thumb: prefetch on hover/intent; do not prefetch every possible route eagerly or you'll waste bandwidth.

Server state is data that lives on a remote server and is fetched asynchronously — it can change outside your app (another user edits it, a cron job updates it). Client state is ephemeral UI state that only your app owns: modal open/closed, form draft, selected tab.

// Client state — lives only in the browser
const [isModalOpen, setIsModalOpen] = useState(false)

// Server state — a snapshot of remote data
const { data: todos } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
})
// "todos" can become stale while the user is on the page

Server state has extra concerns client state doesn't: caching, staleness, background synchronisation, deduplication, pagination. Managing it with useState forces you to re-implement all of these manually.

Rule of thumb: use useState/useReducer/Zustand for client state; use React Query (or SWR) for server state — keep the two layers separate.

Include the page number in the query key so each page has its own cache entry. Use placeholderData: keepPreviousData (v5) to avoid blanking the UI between pages.

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

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

  const { data, isPlaceholderData } = useQuery({
    queryKey: ['todos', page],
    queryFn: () => fetchTodos(page),
    placeholderData: keepPreviousData, // keep showing page N while N+1 loads
  })

  return (
    <>
      {isPlaceholderData && <LoadingBar />}
      <List items={data?.items} />
      <button
        onClick={() => setPage(p => p - 1)}
        disabled={page === 1}>Prev</button>
      <button
        onClick={() => setPage(p => p + 1)}
        disabled={!data?.hasMore}>Next</button>
    </>
  )
}

React Query can also prefetch the next page when hasMore is true, making forward navigation feel instant.

Rule of thumb: always put pagination state in the query key; never mutate an external variable that the key doesn't reflect.

Use React Query when data originates from a server and your main concerns are caching, staleness, and background sync. Use Redux when you have complex cross-cutting client state (undo/redo, multi-step workflows, real-time collaborative state shared between many components).

// React Query — server data, auto-caching, zero boilerplate
const { data: user } = useQuery({ queryKey: ['user'], queryFn: getUser })

// Redux (RTK Query) — same fetching capability, but adds global client state
const user = useSelector(selectUser)       // cross-slice derivations
dispatch(userSlice.actions.setRole('admin')) // synchronous client mutation

Many teams use both: React Query for server state, Zustand or Redux for the small amount of true client state (auth session, UI preferences). RTK Query (Redux Toolkit's built-in fetching solution) is an alternative if you're already invested in Redux.

Rule of thumb: reach for React Query first; only add Redux if you genuinely need global synchronous state across many parts of the app.

Both SWR (Vercel) and React Query implement stale-while-revalidate caching. The differences are in scope and power:

// SWR — minimal, opinionated API
const { data, error, isLoading } = useSWR('/api/user', fetcher)

// React Query — richer API: mutations, infinite queries, optimistic updates
const { data, isLoading } = useQuery({ queryKey: ['/api/user'], queryFn: fetcher })
const mutation = useMutation({ mutationFn: updateUser })

SWR is simpler — almost no configuration, tiny bundle. React Query has more features: useMutation with full lifecycle callbacks, useInfiniteQuery, QueryClient for server-side prefetching, advanced select transforms, and the excellent DevTools panel.

Rule of thumb: SWR for small apps that only need fetch-and-cache; React Query when you also need mutations, optimistic updates, pagination, or fine-grained cache control.

Pass useSuspenseQuery (v5) instead of useQuery to let React Suspense handle the loading state declaratively. The component suspends while data loads; wrap it in <Suspense> with a fallback.

// v5 API
import { useSuspenseQuery } from '@tanstack/react-query'

function UserProfile({ userId }) {
  // throws a Promise while loading → Suspense catches it
  const { data } = useSuspenseQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  })
  // data is ALWAYS defined here — no isLoading check needed
  return <h1>{data.name}</h1>
}

// Parent
function Page() {
  return (
    <ErrorBoundary fallback={<ErrorPage />}>
      <Suspense fallback={<Skeleton />}>
        <UserProfile userId={1} />
      </Suspense>
    </ErrorBoundary>
  )
}

Error states bubble to the nearest <ErrorBoundary> instead of being returned as a flag. This moves loading and error handling out of the data component and into the layout layer.

Rule of thumb: use Suspense mode when you want co-located data fetching with parent-controlled loading UI; keep the imperative isLoading approach in forms and components that handle their own error display.

Use HydrationBoundary with dehydrate to pass the server-fetched cache to the client, avoiding a client-side refetch on first render.

// app/users/page.tsx (Next.js App Router)
import { dehydrate, HydrationBoundary, QueryClient }
  from '@tanstack/react-query'

export default async function UsersPage() {
  const queryClient = new QueryClient()

  // prefetch on the server
  await queryClient.prefetchQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  })

  return (
    // serialise the cache and send it to the client
    <HydrationBoundary state={dehydrate(queryClient)}>
      <UserList />   {/* useQuery(['users']) hits the cache immediately */}
    </HydrationBoundary>
  )
}

On the client, React Query reads the dehydrated state and populates its cache before components mount, so they render with data immediately and no hydration mismatch occurs.

Rule of thumb: always create a fresh QueryClient per request on the server — never share the same instance across requests or you'll leak data between users.

Set refetchInterval to a millisecond value. React Query will refetch the query on that interval while the component is mounted. Set refetchIntervalInBackground: true to keep polling even when the tab is not focused.

const { data: jobStatus } = useQuery({
  queryKey: ['job', jobId],
  queryFn: () => fetchJobStatus(jobId),
  refetchInterval: 3000,               // poll every 3 seconds
  refetchIntervalInBackground: false,  // pause when tab is hidden (default)
})

// Dynamic interval — stop polling once the job is done
const { data } = useQuery({
  queryKey: ['job', jobId],
  queryFn: () => fetchJobStatus(jobId),
  refetchInterval: (query) => {
    // return false to stop; return ms to continue
    return query.state.data?.status === 'done' ? false : 2000
  },
})

The function form of refetchInterval receives the current query object, letting you implement "poll until done" patterns without extra state.

Rule of thumb: always use the function form when you want to stop polling on a condition — a fixed interval that never stops wastes network on completed resources.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel