ReactCaching & Revalidation

Caching & Revalidation

Every time you fetch data, a fundamental tension exists between two goals: showing fresh data (accuracy) and showing data fast (performance). Caching is the mechanism that lets you have both — store a copy of the response, show it instantly, then fetch an update in the background. Revalidation is the process of replacing stale cache entries with fresh ones.

What Is a Cache?

In the context of React data fetching, a cache is an in-memory store that maps cache keys to previously fetched data. When a component mounts and requests data, the library first checks whether a cache entry exists for that key. If the entry is still fresh, the library returns it immediately — no network request. If the entry is stale (or absent), it fetches fresh data and updates the cache.

Cache Keys

Cache keys uniquely identify a piece of server state. In TanStack Query they are arrays; in SWR they are strings (usually URLs). Every variable that affects the response should be part of the key:

JSX
// TanStack Query — array keys
useQuery({ queryKey: ['posts'],                    queryFn: fetchPosts })
useQuery({ queryKey: ['posts', postId],            queryFn: () => fetchPost(postId) })
useQuery({ queryKey: ['posts', postId, 'comments'],queryFn: () => fetchComments(postId) })
useQuery({ queryKey: ['users', { status: 'active', page: 2 }], queryFn: fetchUsers })

// SWR — string keys (typically URLs)
useSWR('/api/posts', fetcher)
useSWR(`/api/posts/${postId}`, fetcher)
useSWR(`/api/users?status=active&page=2`, fetcher)
Note
If you forget to include a variable in the key, the cache will return data for the old variable even after it changes. Always include every value that the queryFn reads from outer scope.
Stale Time vs Cache Time

Setting

Meaning

Default (TanStack Query)

staleTime

How long data is considered fresh. No background refetch during this window.

0 ms (always stale)

gcTime (cacheTime)

How long inactive data stays in memory before garbage collection.

5 minutes

JSX
// User profile — cache for 10 minutes, fresh for 5 minutes
useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
  staleTime: 1000 * 60 * 5,    // 5 minutes fresh
  gcTime:    1000 * 60 * 10,   // keep in memory for 10 minutes
})

// Exchange rate — always fetch fresh (no staleTime)
useQuery({
  queryKey: ['exchangeRate', currency],
  queryFn: () => fetchRate(currency),
  staleTime: 0,                // immediately stale
  refetchInterval: 1000 * 30,  // poll every 30 seconds
})
The Stale-While-Revalidate Strategy

Stale-while-revalidate (SWR) is an HTTP cache-control directive that became a popular React data fetching pattern. It works in two steps:

  • Immediately serve the cached (stale) data so the user sees something instantly.

  • Simultaneously fetch fresh data in the background and update the UI when it arrives.

The user never sees a loading spinner for data they have seen before. The transition from stale to fresh is seamless — the UI updates in-place when fresh data arrives.

Cache Invalidation

Cache invalidation tells the library that a cache entry is outdated and should be refetched. There are three main triggers:

  • On mutation — after a write operation (create/update/delete), invalidate the affected queries.

  • Time-based — use staleTime or refetchInterval to expire data automatically.

  • Manual — call invalidateQueries / mutate explicitly (e.g. a "Refresh" button).

JSX
// TanStack Query: invalidate after mutation
const queryClient = useQueryClient()

const deleteMutation = useMutation({
  mutationFn: (id) => fetch(`/api/posts/${id}`, { method: 'DELETE' }),
  onSuccess: () => {
    // Invalidate ALL queries that start with ['posts']
    queryClient.invalidateQueries({ queryKey: ['posts'] })
  },
})

// Manual refetch (e.g. a Refresh button)
queryClient.invalidateQueries({ queryKey: ['posts'] })
Optimistic Updates

An optimistic update immediately reflects the expected result of a mutation in the UI — before the server confirms it. If the server returns an error, the UI is rolled back. This makes the app feel instant even on slow connections:

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

function TodoList() {
  const queryClient = useQueryClient()

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

    // Called BEFORE the mutationFn fires
    onMutate: async ({ id, done }) => {
      // 1. Cancel any in-flight queries that would overwrite our optimistic update
      await queryClient.cancelQueries({ queryKey: ['todos'] })

      // 2. Snapshot the current cache value (for rollback)
      const previous = queryClient.getQueryData(['todos'])

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

      // 4. Return context for rollback
      return { previous }
    },

    // If the mutation fails, roll back to the snapshot
    onError: (_err, _variables, context) => {
      queryClient.setQueryData(['todos'], context.previous)
    },

    // Always refetch after success or error to sync with server truth
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    },
  })

  // ... render todos
}
Warning
Optimistic updates require careful rollback logic. Always capture the previous cache state in onMutate and restore it in onError. If you forget, a failed mutation will leave the UI in a broken state.
Background Refetching

TanStack Query and SWR automatically trigger background refetches in several situations to keep data fresh without user interaction:

  • Window focus — user returns to the browser tab after being away.

  • Network reconnect — device comes back online after going offline.

  • Component mount — a component mounts and the cached data is stale.

  • Interval pollingrefetchInterval triggers fetches on a timer.

JSX
// Disable automatic refetching for static data
useQuery({
  queryKey: ['countries'],
  queryFn: fetchCountries,
  staleTime: Infinity,           // never goes stale
  refetchOnWindowFocus: false,   // don't refetch on focus
  refetchOnMount: false,         // don't refetch when component mounts
})
Cache Prefetching

Prefetching loads data into the cache before a component that needs it mounts — for example, on hover over a link, or during server-side rendering:

JSX
// Prefetch on hover so the page loads instantly on click
function PostLink({ postId, title }) {
  const queryClient = useQueryClient()

  function handleMouseEnter() {
    queryClient.prefetchQuery({
      queryKey: ['post', postId],
      queryFn: () => fetchPost(postId),
      staleTime: 1000 * 60,   // only prefetch if older than 1 minute
    })
  }

  return (
    <a href={`/posts/${postId}`} onMouseEnter={handleMouseEnter}>
      {title}
    </a>
  )
}
Tip
Prefetching is one of the most impactful performance wins available. Hover-to-prefetch on navigation links makes page transitions feel near-instant because the data is already in the cache when the target component mounts.