React Query (TanStack Query): Server State Caching

Medium•

React Query solves the hard parts of server state: caching, synchronization, background refresh, request deduplication, and mutation consistency. The interview-level understanding is knowing when to invalidate vs when to update cache directly, and how staleTime/gcTime change network behavior and UX.

Quick Decision Guide

### Quick Setup

1) Add Provider

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

const queryClient = new QueryClient()

export function AppProviders({ children }) {
  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  )
}

2) Fetch data (useQuery)

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

function User({ userId }) {
  const q = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    staleTime: 60_000
  })

  if (q.isLoading) return 'Loading...'
  if (q.error) return 'Error'
  return q.data.name
}

3) Update data (useMutation)

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

function EditUser({ userId }) {
  const qc = useQueryClient()

  const m = useMutation({
    mutationFn: updateUser,
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['user', userId] })
    }
  })

  return <button onClick={() => m.mutate({ id: userId, name: 'John' })}>Save</button>
}

Big idea: React Query owns server state. Use keys, staleTime, invalidation, and cache updates to keep UI fast + correct.

Query Keys (Cache Identity)

QueryKey Structure

Query keys are the identity of cached data. Prefer arrays:

['users']
['user', userId]
['posts', { status: 'published', page: 1 }]

Rules

•Unique: different data => different key
•Stable: avoid inline objects that change identity unless content is stable
•Hierarchical: enables partial invalidation

Query function

•returns a Promise
•throws on error
async function fetchUser(id) {
  const res = await fetch(`/api/users/${id}`)
  if (!res.ok) throw new Error('Failed')
  return res.json()
}

Caching Strategy (staleTime, gcTime, refetch triggers)

staleTime

How long data is considered fresh. Fresh queries don't refetch on mount/focus.

useQuery({
  queryKey: ['user', id],
  queryFn: () => fetchUser(id),
  staleTime: 5 * 60 * 1000
})

gcTime (garbage collection)

How long inactive queries remain in memory before removal.

Refetch Triggers (defaults can vary by config)

•window focus
•network reconnect
•mount (if stale)
•manual invalidation

Practical defaults

•Highly dynamic data (feeds): low staleTime
•Mostly static (settings/profile): higher staleTime

Mutations: Correctness vs Instant UX

invalidateQueries (correctness-first)

After a mutation, refetch related queries.

const qc = useQueryClient()

useMutation({
  mutationFn: updateUser,
  onSuccess: (_data, vars) => {
    qc.invalidateQueries({ queryKey: ['user', vars.id] })
    qc.invalidateQueries({ queryKey: ['users'] })
  }
})

setQueryData (instant UI)

Update cache immediately when you know the new state.

onSuccess: (updated) => {
  qc.setQueryData(['user', updated.id], updated)
}

Rule of thumb

•Use invalidateQueries when server is source of truth and relationships are complex.
•Use setQueryData when you can confidently update cached data and want instant UX.

Optimistic Updates (with rollback)

Optimistic Update Pattern

Update cache first, rollback on error.

const qc = useQueryClient()

useMutation({
  mutationFn: updateUser,
  onMutate: async (vars) => {
    await qc.cancelQueries({ queryKey: ['user', vars.id] })

    const prev = qc.getQueryData(['user', vars.id])

    qc.setQueryData(['user', vars.id], (old) => ({
      ...old,
      name: vars.name
    }))

    return { prev }
  },
  onError: (_err, vars, ctx) => {
    qc.setQueryData(['user', vars.id], ctx?.prev)
  },
  onSettled: (_data, _err, vars) => {
    qc.invalidateQueries({ queryKey: ['user', vars.id] })
  }
})

Pitfalls

•forgetting rollback
•optimistic update conflicts with pagination lists
•updating multiple caches inconsistently (detail + list views)

Pagination & Infinite Scroll

Pagination

Keep page params in the query key.

useQuery({
  queryKey: ['posts', { page, filter }],
  queryFn: () => fetchPosts({ page, filter }),
  keepPreviousData: true
})

Infinite queries

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

useInfiniteQuery({
  queryKey: ['posts', { filter }],
  queryFn: ({ pageParam = 0 }) => fetchPosts({ cursor: pageParam, filter }),
  getNextPageParam: (lastPage) => lastPage.nextCursor
})

Next.js App Router Notes

Next.js (App Router)

•React Query is typically used in Client Components.
•For SSR/SEO, consider server fetching + hydration if needed.
•Avoid double-fetch by aligning server fetch and client cache strategy.

Rule:

•Server Components fetch for initial HTML when needed.
•React Query manages client-side caching, background refetch, mutations, and optimistic UI.

Key Takeaways

1React Query manages server state: caching, deduping, background refresh, and mutation consistency.
2Query keys are cache identity—make them stable, unique, and hierarchical.
3Use staleTime to control refetch frequency; use gcTime to control cache retention for inactive queries.
4Mutations: invalidateQueries for correctness; setQueryData for instant UI when safe.
5Optimistic updates require rollback (snapshot previous cache, restore on error).
6Use useInfiniteQuery for infinite scroll and keep pagination params in query keys.