Full Stack
Intermediate 14 min read Full Stack

Data Fetching Patterns

Fetching data is more than calling fetch: it means handling loading, error and empty states, caching and deduplication, pagination, and deciding whether data is fetched on the client or the server. TanStack Query and fetch-then-render are the two dominant patterns.

fetchTanStack Querycachingdeduplicationloading stateSSR datapaginationoptimistic update
Introduction

What is this?

Data fetching patterns determine when, where and how an app loads remote data. The raw approach is fetch-in-effect: call the API in useEffect, store the result in state, and manage loading/error flags yourself. Library-based approaches such as TanStack Query and SWR add caching, background refetching, request deduplication and pagination out of the box. On the server, patterns like React Server Components, getServerSideProps and ISR decide whether data is fetched per request or baked in at build time.

Motivation

Why do you need it?

  • Loading, error and empty states are user-visible; a half-baked fetch leaves the UI blank or frozen.
  • Caching and deduplication stop identical requests from firing for every component that needs the same data.
  • Stale-while-revalidate gives instant renders from cache while refreshing data in the background.
  • Choosing client vs server fetching controls SEO, TTFB, bundle size and how fresh the data is.
  • Pagination and infinite scroll keep large datasets usable instead of shipping every row at once.
Think about it

Mental model

Imagine

Fetching groceries with a pantry

Think of

A caching data-fetching library

You check the pantry (cache) before shopping, buy only what is missing, and restock in the background so guests never wait. Without the pantry, you run to the store every time someone asks for milk.

Mechanics

How it works

  • A query key (like ['items', page]) identifies a cache entry; the library checks the cache before hitting the network.
  • Concurrent components that share a key are deduplicated: only one request is sent and everyone receives the same promise.
  • Stale-while-revalidate returns cached data immediately, then refetches in the background and updates the UI.
  • The query lifecycle is observed: a result is 'loading', 'success', 'error' or 'stale' based on refetch intervals, mutations and window focus.
  • Mutations update the server, then invalidate or optimistically patch the cache so the UI stays consistent.

The TanStack Query request lifecycle.

Component mounts
Cache lookup
Cache hit
Network fetch
Refetch on window focus
UI always has data
Reference

Key commands

npm install @tanstack/react-query

Add the React data-fetching library.

added 8 packages ...
npm install swr

Alternative fetch+cache library from Vercel with the same stale-while-revalidate idea.

npm install @tanstack/react-query-devtools

Install the devtools so you can inspect the cache and query states.

npx react-query@latest query --data-fetching

Scaffold a runnable TanStack Query example.

npm run dev

Start the app so you can observe queries in the devtools panel.

Hands-on

Examples

Fetching with TanStack Query

Fetching with TanStack Query
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
const fetchItems = async () => {
const res = await fetch("/api/items")
if (!res.ok) throw new Error("Failed to load items")
return res.json()
}
function Items() {
const { data, isLoading, isError, error } = useQuery({
queryKey: ["items"],
queryFn: fetchItems,
staleTime: 60_000,
})
if (isLoading) return <p>Loading…</p>
if (isError) return <p role="alert">{error.message}</p>
return data.map((item) => <li key={item.id}>{item.name}</li>)
}

useQuery keys the cache with ['items'], fetches through queryFn, and exposes isLoading/isError/data. staleTime keeps data fresh for a minute, so remounting the component reuses the cache instead of refetching. A mutation later invalidates the key so the list updates after a write.

Real world

In production

  • Dashboards use staleTime + refetchInterval to keep metrics current without spamming the API.
  • Search-as-you-type debounces input and cancels the previous request so only the latest query wins.
  • Infinite queries power social feeds and product lists, appending pages as the user scrolls.
  • Optimistic mutations make likes and cart updates feel instant while the server syncs in the background.
Guidelines

Best practices

  • Give every query a stable, descriptive key; keys are the cache's identity — ['item', id] not ['item'].concat(id).
  • Centralize API calls in a fetcher module so error handling, timeouts and auth headers live in one place.
  • Set staleTime and gcTime deliberately instead of relying on defaults (0 and 5 minutes).
  • Always handle the empty-data case: a successful 200 with zero items is a different UI than an error.
  • For SSR frameworks, prefer streaming/server fetching for first paint and use client queries for interactivity.
Watch out

Common mistakes

  • Calling fetch directly inside a component render (runs on every render) instead of inside useQuery or an effect.
  • Forgetting AbortController, so stale responses overwrite newer ones in race conditions.
  • Turning off refetchOnWindowFocus globally without thinking about freshness requirements.
  • Embedding the API base URL in every call, making it impossible to switch environments later.
Root cause analysis

Common issues

Error
Error: Failed to load items (thrown from !res.ok)
Root cause

fetch resolves for 404/500; the code forgot to throw when res.ok is false.

Fix

Check res.ok after every fetch and throw with the status so the query lands in the error state.

Error
UI shows old data after a mutation
Root cause

The cache was never invalidated after the write.

Fix

Call queryClient.invalidateQueries({ queryKey: ['items'] }) after the mutation succeeds.

Error
Infinite loop of network requests
Root cause

The fetcher function is recreated with a new identity on every render, or queryKey changes each render.

Fix

Make queryKey a stable constant/array and keep the fetcher outside the component or in useCallback.

When things break

Debugging tips

  • Open the TanStack Query devtools to inspect each query's state (loading/success/error/stale) and cache age.
  • Check the Network tab: if you expected one request but see many, the query key is unstable or no cache exists.
  • Log the error object — TanStack Query wraps thrown errors in Error, and HTTP errors only appear if you throw on !res.ok.
  • Use queryClient.removeQueries or invalidateQueries to force a refetch while debugging a stale-cache bug.
Interviews

Interview checkpoint

Practice

Hands-on lab

Build a Cached Data-Fetching Layer
45 min · intermediate
In progress
Objectives
  • Wire TanStack Query into a React app
  • Create a query with loading, error and empty states
  • Add a mutation that invalidates the list cache
  • Observe deduplication and stale-while-revalidate behavior

Build a Cached Data-Fetching Layer

45 min · intermediate

0/5 steps
0%
sandbox · Linux sandbox
$
Try these commands
Test yourself

Quick quiz

0/3
1

What is the primary job of the queryKey in TanStack Query?

2

fetch resolves successfully even when the server responds with a 500 status code.

3

After a successful POST to create an item, the list still shows the old data. What is missing?

Quick reference

Cheat sheet

Core hooks
useQuery({ queryKey, queryFn, staleTime })Fetch + cache a resource
useMutation(mutationFn)Perform a write
queryClient.invalidateQueries({ queryKey })Refetch after a mutation
useInfiniteQueryPaginated/infinite data
staleTime / gcTimeFreshness vs cache retention
refetchOnWindowFocusBackground refresh on tab focus
Go deeper

Official documentation