# Data fetching & caching

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

Frontend Developer roadmap · TypeScript & React · 18 min read · https://levelcode.org/roadmaps/frontend/data-fetching

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.

> [!NOTE]
> The `as T` here trusts the server, as you saw with assertions in the TypeScript lesson. For data you do not control, validate it with a schema library at this boundary so bad data fails loudly in one place.

## 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 state | Server state |
| --- | --- |
| Owned by your app | Owned by the server; you hold a copy |
| Always up to date | Can be changed by others and go stale |
| Available synchronously | Arrives asynchronously and can fail |
| Local to a component | Often 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:

| Flag | True when |
| --- | --- |
| `isPending` | There is no data yet |
| `isError` | The last attempt failed (after retries) |
| `isSuccess` | Data is available |
| `isFetching` | A request is in flight, including background refreshes |
| `isLoading` | The 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.

```sequence
title: One query, two components, one cache
participants: Component A, Component B, Query cache, Server
Component A -> Query cache: useQuery(userQuery('1'))
> Component A mounts. There is no entry for ['users', '1'] yet: a cache miss, so A renders its isPending state.
Query cache -> Server: GET /api/users/1
> The cache runs the queryFn. Any other component asking for the same key now would share this request.
Server --> Query cache: 200 OK
> The user arrives as JSON. The result is stored under the key and counts as fresh for staleTime, 60 seconds here.
Query cache --> Component A: data (status: success)
> A re-renders with the data.
Component B -> Query cache: same query key
> Component B mounts and calls useQuery(userQuery('1')) too, while the data is still fresh.
Query cache --> Component B: cached data
> B renders the data immediately, with no spinner and no network call.
note Component A, Server: 60 s later the entry turns stale
> Stale does not mean deleted: both components keep showing the cached data.
note Component A, Server: The window regains focus
> The user switches back to the tab. Queries that are stale and in use refetch when the window regains focus.
Query cache -> Server: GET /api/users/1
> A background refetch: isFetching is true, but isPending stays false, so the old data stays on screen instead of a spinner.
Server --> Query cache: 200 OK, new data
Query cache --> Component A: updated data
Query cache --> Component B: updated data
> Every component using the key re-renders with the new data. That is stale-while-revalidate.
```

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.

> [!WARNING]
> By default a failed query is retried 3 times with exponential backoff before `isError` becomes true, so errors take several seconds to appear. That is good for flaky networks, but remember it when debugging, and turn retries off in tests.

## 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.

> [!TIP]
> Install `@tanstack/react-query-devtools` and render `<ReactQueryDevtools />` inside the provider. Watching entries turn fresh, stale and inactive makes the cache lifecycle click.

## 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.

**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.

## Quiz

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

   A. A spinner shows while the data is fetched from scratch
   B. The cached data is shown immediately and a background refetch updates it
   C. The cached data is shown and never refetched until gcTime expires
   D. The query throws because the data is stale

   **Answer: B.** The entry is still in the cache (gcTime defaults to 5 minutes), so it renders instantly. Because it is stale, mounting triggers a background refetch: stale-while-revalidate.

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

   A. Nothing; the key is only a label
   B. TanStack Query throws a runtime error
   C. The request is sent twice
   D. All categories share one cache entry, so changing category does not trigger a new fetch and the wrong products can be shown

   **Answer: D.** The key identifies the cached data. Every variable the queryFn depends on must be in the key, for example ['products', category], so each category gets its own entry and changes trigger fetches.

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

   A. isFetching is true; isPending is false
   B. isPending is true; isFetching is false
   C. Both isPending and isFetching are true
   D. isLoading is true

   **Answer: A.** isPending means 'no data yet', which is false here. isFetching is true for any in-flight request, including background refreshes. isLoading is isPending && isFetching, so it is also false.

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?

   A. Marks the query as an error, because 404 is a failure
   B. Retries three times, then shows an error
   C. Treats the 404 body as successful data, because fetch resolved and nothing threw
   D. Cancels the query

   **Answer: C.** TanStack Query only knows about failures when the promise rejects. fetch resolves for HTTP error statuses, so you must check res.ok and throw.
