Part 8 · 5 chapters · ~35 min

Data Fetching Patterns

The tree's depth should not be the request schedule, and a component should read data rather than fetch it. This part is fetch-on-render and its waterfalls and races, render-as-you-fetch with route loaders and Suspense and a cache, the cache's knobs, mutations with invalidation and optimistic updates with rollback, the Server Components hybrid, and the tools.

39

Fetch-on-render and its waterfalls

the question

"Every component fetches its own data in useEffect. It works. Why is the page slow and why are there race conditions?"

Because the tree's depth has become the request schedule: a child cannot start fetching until its parent has rendered with data, so three nested components are three sequential round trips. And because useEffect plus useState per component is a cache with no sharing, no deduplication, no staleness model, and a race between responses unless each effect cancels. The fixes are structural: start requests at the route, not in components; share them through a cache; let components suspend on results.

code
// the useEffect fetch, done as right as it can be, and why it is still wrong
function Post({ id }) {
  const [post, setPost] = useState(null); const [error, setError] = useState(null)
  useEffect(() => {
    const ac = new AbortController()                              // cancel on id change or unmount: prevents the late-response race
    setPost(null)                                                 // reset for the new id (or the old post shows under the new id)
    fetch(`/api/posts/${id}`, { signal: ac.signal }).then(r => r.ok ? r.json() : Promise.reject(r)).then(setPost).catch(e => { if (e.name !== 'AbortError') setError(e) })
    return () => ac.abort()
  }, [id])
  if (error) return <Err />; if (!post) return <Spinner />; return <Article post={post} />
}
// correct, and: no cache (remount refetches), no dedupe (two Posts fetch twice), no background refresh, no retry, a waterfall if nested,
// Strict Mode double-mount fetches twice in dev (the abort handles it, which is the point of Strict Mode), and 12 lines per resource.
// the query-cache version: const { data: post, error, isPending } = useQuery({ queryKey: ['post', id], queryFn: ({ signal }) => api.post(id, { signal }) })
// the server-component version: const post = await db.posts.find(id)   (no effect, no state, no loading flag: the boundary is the loading UI)
what goes wrong with fetch-on-render
  1. Waterfalls: each nesting level adds a round trip. Three levels at 150 ms each is 450 ms before the deepest content, versus 150 to 200 ms if started together.
  2. No sharing: two components needing the same resource fetch it twice; a remount fetches again; navigating back refetches what was just shown.
  3. Races: change the id twice quickly; the first response may arrive last and win. The AbortController cleanup is the minimal fix, and it is easy to omit.
  4. Loading states everywhere: each component manages its own spinner, so the page assembles piecemeal and shifts (CLS, from the browser course).
  5. Strict Mode double-mount fetches twice in development, which is the mode telling you the effect's cleanup must handle it.
the two moves
  1. Hoist the start: the route knows its params before any component renders; start every request there (a loader, a server component, a prefetch) in parallel where independent.
  2. Cache the result: a query cache keyed by resource so that any component reads the same in-flight or cached entry; components become readers, not fetchers.
FETCH-ON-RENDER VERSUS RENDER-AS-YOU-FETCH
the same page, two timelines
swipe the figure sideways, or tap expand for full screen
1/6
the dependencies
The page: Post (needs the post), inside it Author (needs the author, whose id is in the post), inside it AuthorStats (needs stats by author id). Three requests; the second depends on the first's result; the third on the second's.
40

Render-as-you-fetch, Suspense, and the cache

code
// the patterns, in the order they should be reached for
// 1. route-level loading (render-as-you-fetch): the router starts every request for the route at navigation; components read results
const router = createBrowserRouter([{ path: '/posts/:id', loader: ({ params }) => Promise.all([api.post(params.id), api.comments(params.id)]), element: <Post /> }])
// 2. deferred data: critical data awaited in the loader; secondary data returned as a promise and read with <Await> / use() under a Suspense boundary
loader: ({ params }) => ({ post: await api.post(params.id), comments: api.comments(params.id) })   // comments streams in after the post renders
// 3. prefetch: on link hover or in-viewport, start the next route's loader (or queryClient.prefetchQuery) so navigation finds the cache warm
<Link to={`/posts/${id}`} prefetch="intent" />
// 4. pagination and infinite: useInfiniteQuery with getNextPageParam; keepPreviousData / placeholderData for a stable list during page changes
// 5. polling and realtime: refetchInterval for cheap freshness; a websocket that writes into the cache (setQueryData) for push; invalidate on reconnect
// 6. dependent queries: enabled: !!userId, so the second starts when the first's result exists; still a waterfall unless the API embeds
// 7. mutations with optimistic updates (the figure) and invalidation (above)
// what to avoid: fetching in components that are deep in the tree with no boundary above them; fetching the same resource under different keys;
// storing server data in a reducer after fetching it (two caches); useEffect fetches for anything the route could have loaded
the shape of a good data layer
  1. Requests start at navigation (or earlier: prefetch on hover, in-viewport, or on idle for likely next routes). The router's loader, the framework's server component, or an explicit prefetchQuery at the link.
  2. Independent requests run in parallel; dependent ones start as soon as their input exists; the API is shaped to remove dependent hops where they matter (embedding, batching, a graph query).
  3. Components read, they do not fetch. useQuery with a key, or use(promise) under a Suspense boundary, or props from a loader. A component that reads a key the route already started gets the in-flight promise, not a second request.
  4. Loading UI is a boundary, not a flag. Suspense boundaries at the granularity of skeletons the user understands; critical data awaited before the shell renders, secondary data streamed in (deferred).
  5. Errors are boundaries too: an error boundary per region catches a rejected read; a retry re-renders the boundary.
the cache's knobs
  1. Keys: hierarchical arrays (['posts', id, 'comments']) so invalidation can target a prefix.
  2. staleTime: how long a cached entry is served without a background refetch. Default 0 (always refetch on mount or focus) is safe and chatty; set it per resource (a user profile: minutes; a stock price: seconds; a config: hours).
  3. gcTime: how long unused entries stay for an instant remount (default 5 minutes).
  4. Refetch triggers: window focus, reconnect, interval, mount if stale. Turn off the ones that do not fit (a form page should not refetch on focus and clobber edits).
  5. Structural sharing: on by default; equal subtrees keep references so bailouts hold (part 2).
  6. Suspense mode (useSuspenseQuery): the query throws until ready; the boundary shows the fallback; the data is non-nullable in the component.
41

Mutations, invalidation, optimistic updates

code
// invalidation: the three questions after a mutation
const qc = useQueryClient()
const createComment = useMutation({
  mutationFn: api.comments.create,
  onSuccess: (newComment, vars) => {
    qc.invalidateQueries({ queryKey: ['comments', vars.postId] })           // 1. what lists contain it? refetch them (or setQueryData to append)
    qc.setQueryData(['post', vars.postId], old => ({ ...old, commentCount: old.commentCount + 1 }))   // 2. what aggregates changed? patch them
    qc.invalidateQueries({ queryKey: ['user', 'me', 'activity'] })           // 3. what else observes it? invalidate broadly; it refetches lazily on use
  },
})
// keys are hierarchical: ['comments'] invalidates every comments query; ['comments', 42] only that post's. design keys as paths.
// staleTime decides whether a refetch is needed at all on the next read; gcTime decides how long unused data stays for an instant remount.
// with RSC: revalidateTag('comments') on the server after the action; the route's server components re-render; the client gets the new payload.
// the rule: a mutation's onSuccess lists every key whose data could have changed. missing one is a stale screen; listing too many is a few extra requests.
mutations
  1. useMutation (or a server action): a function that changes server state, with pending, error and success states for the UI, and hooks (onMutate, onSuccess, onError, onSettled) for cache coordination.
  2. After success, the cache is wrong until told otherwise. Two ways to fix it: invalidate (mark keys stale; they refetch when next observed or immediately if mounted) or patch (setQueryData with the response or a computed update). Invalidate for lists and aggregates whose shape you cannot easily recompute; patch for the entity you got back.
  3. Key design decides invalidation cost: prefixes let one call invalidate a family; too-flat keys force listing every one.
  4. Concurrency: two mutations on the same entity in flight: serialise them (disable while pending, or a mutation queue), or design the API to be idempotent and order-independent.
optimistic updates
  1. When: reversible, low-stakes, high-frequency actions whose success rate is high: likes, toggles, reorders, draft saves, marking read. Never for payments, sends, deletions without undo, or anything whose failure the user would not notice.
  2. The sequence: cancel in-flight refetches for the key (so they cannot overwrite the optimistic write); snapshot; write the optimistic value; send; on success, reconcile (invalidate or patch with the response); on error, restore the snapshot and tell the user.
  3. useOptimistic (React 19) layers an optimistic value over real state only while an action is pending, with no manual rollback: when the action settles, the real state (updated through the normal path) shows. The right primitive for form submissions with actions (part 9).
  4. The failure mode: optimistic state that lingers because the error path was not handled, or a refetch that races the optimistic write because cancelQueries was skipped. Both are visible as "it flickered back".
AN OPTIMISTIC UPDATE, WITH ROLLBACK
the cache edited before the server answers
swipe the figure sideways, or tap expand for full screen
1/6
click
State: the post in the cache has likes: 41, liked: false. The user clicks like. Nothing has been sent yet.
42

Server Components, actions, and the hybrid

fetching in an RSC app
  1. Server components fetch inline (await in the component). The framework deduplicates identical fetches within a request (React's cache() for non-fetch calls; the fetch cache for fetch), caches results per route segment with tags, and streams the tree as each await resolves under Suspense boundaries. No client request, no client cache, no loading flag.
  2. Parallelism still matters: two sequential awaits in one component are a waterfall on the server. Start promises first, await after (const [a, b] = await Promise.all([fa(), fb()])), or split into sibling components that each await their own data (siblings render in parallel under one boundary).
  3. Revalidation replaces client invalidation: after an action, revalidateTag('posts') or revalidatePath('/posts') marks the server cache; the next render (which the action's response can trigger) fetches fresh.
  4. The client router's cache holds route payloads for instant back navigation; its staleness rules are the framework's (Next.js: static versus dynamic segments, staleTimes).
what stays on the client
  1. User-specific, frequently changing data that the page should update without a navigation: notifications, presence, live prices. A client query cache or a websocket writing into one.
  2. Interactive reads driven by client state: search-as-you-type, filters applied client-side, infinite scroll. useQuery keyed by the client state, or a server action called with the state (which returns data, not a tree).
  3. Offline and optimistic UI: the client cache is the only place that can hold an optimistic value or a queued mutation while offline.
the hybrid in one page
  1. Server components for the content of the route: fetched on the server, cached by tag, streamed.
  2. A client query cache for live, personalised, or client-state-driven data, with its own keys and staleness.
  3. Actions for mutations, revalidating server caches and invalidating client ones in onSuccess.
  4. One rule for both: start every request as early as the information to start it exists, and never fetch what is already in flight.
43

Reading data fetching: tools

QuestionToolWhat to read
Is there a waterfall?Network panel waterfall: requests starting after a previous one finished, in a staircaseDependent starts that could be parallel; a component-level fetch that the route could have started
Are requests duplicated?Network panel: the same URL twice within a secondTwo components fetching outside a shared cache; a missing key or a key with an unstable part (a new object in the key)
What is in the cache and how stale?TanStack Query Devtools / SWR devtools / Apollo devtoolsEach key's data, updatedAt, stale/fresh, observers, and the fetch status
Why did this refetch?Query devtools: the trigger (mount, focus, interval, invalidation, manual)A staleTime of 0 on a resource that does not change; focus refetch on a form page
Did the mutation update the right keys?Query devtools after the mutation: which keys were invalidated or setA list still showing the old item means its key was not in onSuccess
Is the optimistic update racing?Network panel timing of the refetch versus the mutation; devtools showing the optimistic value replaced earlycancelQueries missing in onMutate
Where does the RSC payload spend its time?The framework's dev overlay (Next.js: server timings); the Network panel's text/x-component response timing; server logs per fetchSequential awaits in a server component; an uncached fetch on every request
Is Suspense showing fallbacks too long or flashing?React DevTools Profiler (Suspense boundaries and their states); Performance panel for the commit timingA boundary too high (hides too much); a resolve under 100 ms showing a spinner (wrap the trigger in a transition)
the pointer
Part 9 is forms: controlled and uncontrolled inputs, validation timing, large forms that do not re-render per keystroke, actions with useActionState and useFormStatus, and the money form. Mutations in this part were buttons; the next part is when they are forty fields.