Step 17 of 21

Data fetching & caching

Handle loading and error states, avoid fetch race conditions, and manage server state with TanStack Query queries and mutations.

18 min 8 sections

Almost every app shows data that lives on a server. Fetching it looks easy: call fetch, put the result in state. Then the bugs arrive: flashes of stale data, responses arriving out of order, the same request fired five times, lists that do not update after you save. This lesson shows how to fetch correctly by hand, why server data needs different treatment from ordinary state, and how TanStack Query handles caching, refetching and updates for you.

Every request has three states

A request is not a value; it is a process that is pending, then either succeeds or fails. Your UI must handle all three, every time. A small helper makes the failure case reliable:

ts
// src/api.ts
export async function getJson<T>(url: string, signal?: AbortSignal): Promise<T> {
  const res = await fetch(url, { signal })
  if (!res.ok) {
    throw new Error(`Request failed with status ${res.status}`)
  }
  return (await res.json()) as T
}

The res.ok check matters because fetch only rejects on network failures. A 404 or 500 response resolves normally, so without the check, an error page would be treated as data.

Fetching in an effect, done properly

Before reaching for a library, see what correct hand-written fetching involves. Here is a profile that loads whenever userId changes:

tsx
import { useEffect, useState } from 'react'
import { getJson } from './api'

type User = { id: string; name: string; email: string }
type State =
  | { status: 'loading' }
  | { status: 'success'; user: User }
  | { status: 'error'; message: string }

export function UserProfile({ userId }: { userId: string }) {
  const [state, setState] = useState<State>({ status: 'loading' })

  useEffect(() => {
    const controller = new AbortController()
    setState({ status: 'loading' })

    getJson<User>(`/api/users/${userId}`, controller.signal)
      .then((user) => {
        if (!controller.signal.aborted) setState({ status: 'success', user })
      })
      .catch((err: unknown) => {
        if (controller.signal.aborted) return
        setState({ status: 'error', message: err instanceof Error ? err.message : 'Unknown error' })
      })

    return () => controller.abort()
  }, [userId])

  if (state.status === 'loading') return <p>Loading…</p>
  if (state.status === 'error') return <p role="alert">{state.message}</p>
  return <h2>{state.user.name}</h2>
}

The cleanup is the important part. Suppose the user clicks profile 1, then quickly profile 2. Without it, both requests race; if profile 1's response arrives last, the page shows the wrong user under profile 2's URL. Aborting the previous request when userId changes, and ignoring anything from an aborted request, eliminates that race condition. It also stops updates after the component unmounts.

That is a lot of code for one request, and it still lacks a great deal:

  • Navigate away and back, and it refetches from scratch with a spinner, even though it had the data a moment ago.
  • Two components that need the same user fire two identical requests.
  • Data never refreshes, so it goes stale while the tab sits open.
  • After you edit the user, nothing tells this component to reload.
  • A momentary network blip shows an error instead of retrying.

Server state is a different kind of state

useState is ideal for client state: values your app owns outright, like whether a dropdown is open or the text in an input. Data from an API is server state, and it behaves differently:

Client stateServer state
Owned by your appOwned by the server; you hold a copy
Always up to dateCan be changed by others and go stale
Available synchronouslyArrives asynchronously and can fail
Local to a componentOften needed in many places at once

What you really need for server state is a cache: a shared store, keyed by what was requested, that knows when each entry was fetched, deduplicates requests, refreshes stale entries and can be told when entries become invalid. Writing that well is hard, so use a library. TanStack Query is the most widely used one for React.

TanStack Query basics

Install it and provide a single QueryClient, which holds the cache, at the root of your app:

bash
npm install @tanstack/react-query
tsx
// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { App } from './App'

const queryClient = new QueryClient()

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </StrictMode>,
)

Now the profile component becomes:

tsx
import { useQuery } from '@tanstack/react-query'
import { getJson } from './api'

type User = { id: string; name: string; email: string }

export function UserProfile({ userId }: { userId: string }) {
  const { data, error, isPending, isError, isFetching } = useQuery({
    queryKey: ['users', userId],
    queryFn: ({ signal }) => getJson<User>(`/api/users/${userId}`, signal),
  })

  if (isPending) return <p>Loading…</p>
  if (isError) return <p role="alert">Could not load profile: {error.message}</p>

  return (
    <h2>
      {data.name} {isFetching && <small>(refreshing…)</small>}
    </h2>
  )
}

A query needs two things. The queryFn is any function that returns a promise and throws on failure; TanStack Query passes it an AbortSignal, which it triggers if the query is cancelled. The queryKey is an array that uniquely identifies the data. After the isPending and isError checks, TypeScript knows data is a User.

There are two independent dimensions of status, and mixing them up is a common source of bugs:

FlagTrue when
isPendingThere is no data yet
isErrorThe last attempt failed (after retries)
isSuccessData is available
isFetchingA request is in flight, including background refreshes
isLoadingThe first load is in flight (isPending && isFetching)

Show a full-page spinner on isPending, but show at most a subtle indicator on isFetching; otherwise every background refresh blanks your screen.

Query keys and the cache lifecycle

The query key works like an effect's dependency array: include every variable the queryFn uses. ['users', userId] stores each user under its own entry, and when userId changes the component switches to that entry, fetching it if needed. Keys are compared by value, so ['todos', { status: 'done', page: 2 }] is fine. They are also hierarchical, which matters for invalidation later.

Two components that use the same key share one cache entry and one request. Each cached entry then moves through a lifecycle governed by two settings:

  • staleTime (default 0): how long data counts as fresh. Fresh data is served from the cache without refetching. Stale data is still shown instantly, but a background refetch is triggered when a new component using the query mounts, when the window regains focus and when the network reconnects.
  • gcTime (default 5 minutes): how long data for a query that no component is using stays in memory before it is garbage-collected.

This is the stale-while-revalidate pattern: users see cached data immediately and fresh data shortly after. The default staleTime of zero is deliberately cautious. For data that changes rarely, raise it:

ts
import { queryOptions } from '@tanstack/react-query'

export const userQuery = (userId: string) =>
  queryOptions({
    queryKey: ['users', userId],
    queryFn: ({ signal }) => getJson<User>(`/api/users/${userId}`, signal),
    staleTime: 60_000,
  })

// In a component:            useQuery(userQuery(userId))
// Before navigation (hover): queryClient.prefetchQuery(userQuery(userId))

queryOptions keeps the key, function and options together in one typed object, so every place that uses the query stays consistent.

Here is that lifecycle for userQuery('1'), with its 60-second staleTime, when two components use it. Look for the one network request that serves both components, and for the refetch that happens in the background while cached data stays on screen.

DiagramOne query, two components, one cache
Component AComponent BQuery cacheServer1useQuery(userQuery('1'))2GET /api/users/13200 OK4data (status: success)5same query key6cached data760 s later the entry turns stale8The window regains focus9GET /api/users/110200 OK, new data11updated data12updated data

Other options you will use often: enabled: false pauses a query until a condition is met (for example, until a search term is long enough), and placeholderData: keepPreviousData keeps showing the previous page's data while the next page loads, instead of flashing a spinner.

Mutations and invalidation

Reads are queries; writes are mutations. useMutation tracks a single write, and when it succeeds you invalidate the queries it affected, which marks them stale and refetches any that are on screen:

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

type Todo = { id: string; title: string }

async function createTodo(title: string): Promise<Todo> {
  const res = await fetch('/api/todos', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title }),
  })
  if (!res.ok) throw new Error(`Request failed with status ${res.status}`)
  return res.json()
}

export function Todos() {
  const queryClient = useQueryClient()
  const todos = useQuery({
    queryKey: ['todos'],
    queryFn: ({ signal }) => getJson<Todo[]>('/api/todos', signal),
  })

  const addTodo = useMutation({
    mutationFn: createTodo,
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
  })

  return (
    <>
      <form action={(formData) => addTodo.mutate(String(formData.get('title')))}>
        <input name="title" aria-label="New todo" required />
        <button type="submit" disabled={addTodo.isPending}>Add</button>
      </form>
      {addTodo.isError && <p role="alert">{addTodo.error.message}</p>}
      <ul>
        {todos.data?.map((todo) => <li key={todo.id}>{todo.title}</li>)}
        {addTodo.isPending && <li style={{ opacity: 0.5 }}>{addTodo.variables}</li>}
      </ul>
    </>
  )
}

Three details are worth noticing. invalidateQueries({ queryKey: ['todos'] }) matches by prefix, so it also invalidates ['todos', { page: 2 }] and any other key starting with 'todos'. Returning its promise from onSuccess keeps the mutation pending until the refetch finishes, so the button stays disabled until the list is up to date. And rendering addTodo.variables while pending is a simple optimistic update: the new item appears immediately, then the real one replaces it.

Practice

Build a paginated post list against the free test API https://jsonplaceholder.typicode.com/posts?_page=1&_limit=10, which has 100 posts.

  1. Show 10 post titles per page with Previous and Next buttons.
  2. Going back to a page you have already seen must render instantly from the cache.
  3. While the next page loads, keep the current page visible (slightly faded) instead of showing a spinner, and disable Next until it arrives.
  4. Treat data as fresh for 30 seconds.
Show solution
tsx
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { getJson } from './api'

type Post = { id: number; title: string }
const LAST_PAGE = 10

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

  const { data, isPending, isError, error, isPlaceholderData } = useQuery({
    queryKey: ['posts', page],
    queryFn: ({ signal }) =>
      getJson<Post[]>(`https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=10`, signal),
    placeholderData: keepPreviousData,
    staleTime: 30_000,
  })

  if (isPending) return <p>Loading…</p>
  if (isError) return <p role="alert">{error.message}</p>

  return (
    <section>
      <ol start={(page - 1) * 10 + 1} style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
        {data.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ol>
      <button type="button" onClick={() => setPage((p) => p - 1)} disabled={page === 1}>
        Previous
      </button>
      <span> Page {page} </span>
      <button
        type="button"
        onClick={() => setPage((p) => p + 1)}
        disabled={isPlaceholderData || page === LAST_PAGE}
      >
        Next
      </button>
    </section>
  )
}

page is in the query key, so each page is its own cache entry: returning to page 2 reads that entry immediately (and, if older than 30 seconds, refreshes it in the background). keepPreviousData returns the last page's data as a placeholder while the new key loads, and isPlaceholderData tells you when that is happening so you can fade the list and block double-clicks on Next. isPending is only true on the very first load.

Key takeaways

  • Always handle pending, error and success, and throw on non-ok responses, since fetch does not.
  • Hand-written fetching in effects must abort or ignore stale responses to avoid race conditions.
  • Server state is a cache of someone else's data; let TanStack Query deduplicate, cache and refresh it.
  • Put every variable the queryFn uses into the queryKey.
  • staleTime controls when data is refetched; gcTime controls when unused data is discarded.
  • Write with useMutation, then invalidateQueries so affected queries refetch.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.A query uses the default staleTime of 0. The user leaves a page and returns 30 seconds later. What happens?

  2. 2.Your queryFn fetches `/api/products?category=${category}`, but the queryKey is just ['products']. What goes wrong?

  3. 3.A query already has data and is refetching in the background after a window focus. Which flags are true?

  4. 4.The server answers your queryFn's fetch with a 404 and a JSON body `{"message": "Not found"}`. The queryFn returns `res.json()` without checking anything. What does TanStack Query do?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.