React in Depth

Data Fetching, Caching and Server State

fetch in effects and its bugs, race conditions, loading and error states, TanStack Query ideas, and Suspense with use().

Lesson 0515 min read#react#data-fetching#suspense#tanstack-query

On this page

  1. Words used in this lesson
  2. Server state is a different kind of state
  3. The simple version: fetch in an effect
  4. A better hand-made version: a small custom hook
  5. TanStack Query: a cache for server state
  6. Suspense and use() in React 19
  7. A decision table
  8. Try it
  9. Interview questions
  10. Key takeaways
  11. What's next

In one line: data that lives on a server is not the same kind of state as a checkbox on your screen. It can be late, wrong, out of date, shared by many components, or fail. Fetching it inside useEffect works for a toy, but it hides race conditions, duplicate requests and missing error states. Learn the bug by hand first, then see why a cache library like TanStack Query (and React 19's use() with Suspense) exists.

Most React apps are a thin screen over data from an API. So this is where most real bugs live. A user clicks "next profile" quickly, an old answer arrives late, and the screen shows the wrong person. No error is printed. The page just lies.

This is lesson 5 of a ten-lesson series. You should know components, state and effects from Effects and the Lifecycle (lesson 2) and the form basics from Forms, Events and Controlled Inputs (lesson 4). The next lesson, Context, State Management and Choosing Where State Lives, is about client state, the other half of the picture. For the promise basics behind everything here, see Promises and async/await From the Ground Up.

What I ran / checked. Everything ran on Linux with Node.js v22.22.0, react 19.3.0, react-dom 19.3.0, @testing-library/react 16.3.3, @testing-library/user-event 14.6.7, @tanstack/react-query 5.104.1 and vitest 5.0.3 with jsdom. The versions are the "latest" tags on the npm registry on the day I wrote this. I pasted the real output. Timings vary between runs, but the counts do not. Sources I read (explained in my own words): the React docs pages for use, Suspense, useTransition and "You Might Not Need an Effect" (raw markdown from the reactjs/react.dev repository on GitHub), the TanStack Query "Important Defaults" guide from the TanStack/query repository, and the README of pmndrs/zustand. Could not do: I did not run a real network server for React, I used a fake API whose answers I finish by hand, so the order of replies is exactly what I decide. That is the point: real networks reorder replies in the same way, but you cannot make them do it on command. I did not run Suspense with a real streaming server here (that is lesson 8 territory). I give no GitHub star counts because I did not check them.

Words used in this lesson

  • Server state is data whose real home is a server: users, orders, posts. Your screen only holds a copy, and the copy can go out of date.
  • Client state is data that only exists in the browser: which tab is open, what the user typed, whether a menu is open.
  • A race condition is a bug where the result depends on which of two things finishes first. Here: two requests, and the slow old one arrives last.
  • A stale response is an answer to a question you no longer care about (for example the answer for user 1 after you moved to user 2).
  • Stale data (a different meaning) is cached data that may be out of date and could be refreshed.
  • AbortController is a browser and Node tool to cancel an in-flight fetch. You call controller.abort() and the fetch promise rejects with an AbortError.
  • A cache is a place that keeps a copy of an answer so you do not ask again.
  • A query key is the label a cache library uses to name one piece of data, such as ["user", 1].
  • Deduplication means that if three components ask for the same thing at the same time, only one request is sent.
  • Invalidate means "mark this cached data as old, and fetch it again if somebody is using it".
  • A mutation is a request that changes data on the server (create, update, delete). A query only reads.
  • Suspense is a React component that shows a fallback (like a spinner) while something below it is not ready.
  • use() is a React 19 function that reads a promise or a context during render. If the promise is not finished, the component suspends.
  • A waterfall is a chain of requests where each one waits for the one before, so the total time is the sum.

Server state is a different kind of state

Look at these two pieces of state:

JavaScript
const [menuOpen, setMenuOpen] = useState(false); // client stateconst [user, setUser] = useState(null);           // a copy of something on a server

The first one is yours. Only your component can change it, so it is always right. The second one is a photograph. Someone else can change the real user a second later, and your photograph is now old. A photograph also needs to be taken (a request), it can fail to arrive, it can arrive late, and ten components might want the same photograph.

So server state brings a list of questions that client state never asks:

QuestionWhy it matters
Is it loading?The first time, there is nothing to show yet.
Did it fail?Networks fail. A blank page is not an answer.
Is it old?When should we ask again?
Is another component already asking?Do not send three identical requests.
What if the answer is for an old question?The race condition, which we will run below.
What if the user leaves the page?Cancel work nobody needs.
After I change it, how do I refresh it?Update the copy so the screen is true again.

Writing all of this by hand in every component is long and easy to get wrong. That is the whole story of this lesson: first we write it by hand to feel the pain, then we let a library carry it.

The simple version: fetch in an effect

This is what almost everybody writes first. React can do this, and for a small page it is fine:

JavaScript
import { useEffect, useState } from "react";function Profile({ id }) {  const [user, setUser] = useState(null);  const [error, setError] = useState(null);  const [loading, setLoading] = useState(true);  useEffect(() => {    setLoading(true);    fetch(`/api/users/${id}`)      .then((res) => res.json())      .then((data) => { setUser(data); setLoading(false); })      .catch((err) => { setError(err); setLoading(false); });  }, [id]);  if (loading) return <p>Loading...</p>;  if (error) return <p>Something went wrong</p>;  return <h1>{user.name}</h1>;}

Remember from the effects lesson: an effect is code that runs after React has painted the screen, to talk to something outside React. A request to a server is exactly that. The dependency array [id] says "run again when id changes".

This code has four bugs. Let us find them one by one.

Bug 1: fetch does not reject on a 404 or 500

Many people think fetch fails when the server says "not found". It does not. fetch only rejects when the request itself cannot be made (no network, DNS error). A 404 or 500 is a successful delivery of a bad answer. I checked with a tiny local server that always answers 404:

JavaScript
import http from "node:http";const s = http.createServer((q, r) => { r.statusCode = 404; r.end("nope"); }).listen(0, async () => {  const url = `http://127.0.0.1:${s.address().port}/x`;  try { const res = await fetch(url); console.log("fetch resolved, ok =", res.ok, "status =", res.status); }  catch (e) { console.log("rejected", e.message); }  try { await fetch("http://127.0.0.1:1/"); } catch (e) { console.log("network failure rejects:", e.name); }  s.close();});
TEXT
fetch resolved, ok = false status = 404network failure rejects: TypeError

So in the component above, a 404 goes to .then(res => res.json()), which may throw because the body is not JSON, or may happily give you the error body as if it were a user. The fix is to check res.ok yourself:

JavaScript
const res = await fetch(url);if (!res.ok) throw new Error(`HTTP ${res.status}`);const data = await res.json();

Trap: fetch rejecting only on network failure is the most common wrong assumption in interviews and in code. Always check res.ok. (Libraries like axios reject on 4xx and 5xx by default. That difference is why people get confused.)

Bug 2: the race condition

Now the bug that matters most. The user is on profile 1. They click to profile 2 quickly. Two requests are now in the air:

TEXT
time ------------------------------------------------>request for id 1  |----------------- slow ----------------| arrives LASTrequest for id 2     |------ fast ------| arrives first

The screen should show user 2. But the old request for user 1 arrives after the new one, and its .then(setUser) runs and overwrites the screen. The page now says "user 2" in the heading but shows the data for user 1.

I will prove it. I wrote a fake API where each call returns a promise that I finish by hand, so I can force the bad order. The test renders the component with id=1, changes it to id=2, lets the answer for 2 arrive first, then lets the answer for 1 arrive late:

JavaScript
function Buggy({ id, api }) {  const [text, setText] = useState("loading");  useEffect(() => {    api(id).then(setText);            // no cleanup  }, [id]);  return <p data-testid="out">user {id}: {text}</p>;}
TEXT
BUGGY        {"afterSecond":"user 2: profile of user 2","final":"user 2: profile of user 1"}

Read the last part slowly: final is user 2: profile of user 1. The heading says user 2 (that comes from the prop id, which is always right), but the body shows the profile of user 1. This is the bug. It happens in real apps with slow networks, with fast typing in a search box, and with fast tab clicks. It is hard to reproduce, because it needs a specific order of replies.

Why does it happen? Each run of the effect has its own .then(setText). Nothing tells the first run "your result is no longer wanted".

Fix 1: the ignore flag

The effect can return a cleanup function. React calls it before running the effect again (and when the component leaves the screen). We use it to mark "this run is old":

JavaScript
function IgnoreFlag({ id, api }) {  const [text, setText] = useState("loading");  useEffect(() => {    let ignore = false;    setText("loading");    api(id).then((t) => { if (!ignore) setText(t); });    return () => { ignore = true; };  }, [id]);  return <p data-testid="out">user {id}: {text}</p>;}

The variable ignore lives inside one run of the effect (a closure). When id changes from 1 to 2, React calls the cleanup of run 1, so run 1's ignore becomes true. When its late answer arrives, if (!ignore) stops it.

TEXT
IGNORE FLAG  {"afterSecond":"user 2: profile of user 2","final":"user 2: profile of user 2"}

Same scenario, correct end state. The request for user 1 still ran to the end (the server did the work, the bytes were downloaded), we just threw the answer away.

Fix 2: AbortController, which really cancels

An ignore flag ignores. AbortController cancels. You pass its signal to fetch, and call abort() in the cleanup:

JavaScript
function Abortable({ id, api, log }) {  const [text, setText] = useState("loading");  useEffect(() => {    const ctrl = new AbortController();    setText("loading");    api(id, ctrl.signal).then(setText, (e) => {      if (e.name !== "AbortError") setText("error");      else log.push(`id ${id} aborted`);    });    return () => ctrl.abort();  }, [id]);  return <p data-testid="out">user {id}: {text}</p>;}
TEXT
ABORT        {"afterSecond":"user 2: profile of user 2","final":"user 2: profile of user 2"} [ 'id 1 aborted' ]

The correct state again, plus the log shows the first request really was aborted. With a real fetch(url, { signal }), the browser stops the download. Two details matter:

  • An aborted fetch rejects with an error whose name is AbortError. Your .catch must not treat that as a real failure and show "Something went wrong". That is why the code checks e.name.
  • Aborting does not undo anything on the server. If the request was a POST that creates an order, the server may still have created it. Abort is for reads.

Trap: Many people think the "setState on an unmounted component" warning was the reason for the ignore flag. That warning was removed in React 18. The real reason is the wrong data (a race), not the warning.

Why does my request run twice in development?

In my test I also rendered the component inside <StrictMode>, which is what create-vite and Next.js turn on in development. I counted how many requests were sent on first render of one component:

TEXT
requests sent under StrictMode (dev): 2

Two requests for one component. This is deliberate. In development only, React mounts, cleans up, and mounts again to check your cleanup is correct. If your effect is written well (ignore flag or abort), the double run is harmless, and the first request's answer is ignored. If you saw only one request, you might never find a missing cleanup until production. In a production build the effect runs once. We met this in lesson 2.

Trap: "I fixed the double request by removing StrictMode." That hides the problem. The double run is a test, not a bug. If a double request causes real harm (like creating two orders), your effect is doing something that should be in an event handler, not an effect.

Bug 3: nobody shares the answer

Here is the third problem. Imagine a header, a sidebar and a page, all showing the current user. Each one fetches by itself:

JavaScript
function Naive({ label }) {  const [d, setD] = useState(null);  useEffect(() => { getUser(1).then(setD); }, []);  return <p>{label}: {d ? d.name : "loading"}</p>;}// <Naive label="header" /><Naive label="sidebar" /><Naive label="page" />
TEXT
naive useEffect fetches for 3 components: 3

Three components, three identical requests. In a real app with 40 components, going to another page and back sends them all again, with a loading spinner every time, because the data was thrown away when the component left. You can move the fetch up to a parent and pass props down, but then you are inventing a cache by hand.

Bug 4: waterfalls

If a child component fetches in its own effect, it only starts fetching after it has been rendered, and it is only rendered after its parent has finished loading:

TEXT
Parent mounts -> fetch user ........ done -> render children                                              Child mounts -> fetch posts ........ done

Total time is the sum: user time plus posts time. If both requests were independent, they could have started together. This is called a waterfall. The fix is to start requests earlier (in a router loader, on the server, or by starting the promise above the component that needs it) and to avoid putting independent fetches behind each other.

A better hand-made version: a small custom hook

Before a library, many teams write useFetch. It gives you one place to put the ok check, the ignore flag and the three states:

JavaScript
function useFetch(url) {  const [state, setState] = useState({ status: "loading", data: null, error: null });  useEffect(() => {    const ctrl = new AbortController();    setState({ status: "loading", data: null, error: null });    fetch(url, { signal: ctrl.signal })      .then((res) => {        if (!res.ok) throw new Error(`HTTP ${res.status}`);        return res.json();      })      .then((data) => setState({ status: "success", data, error: null }))      .catch((error) => {        if (error.name === "AbortError") return;        setState({ status: "error", data: null, error });      });    return () => ctrl.abort();  }, [url]);  return state;}

Using it is clean:

JavaScript
function Profile({ id }) {  const { status, data, error } = useFetch(`/api/users/${id}`);  if (status === "loading") return <p>Loading...</p>;  if (status === "error") return <p>Failed: {error.message}</p>;  return <h1>{data.name}</h1>;}

Notice we keep a single status string instead of three booleans. Three booleans (loading, error, data) can be in impossible combinations ("loading and error at the same time"). One status cannot. This idea comes from the React docs advice on choosing state structure: avoid states that contradict each other.

This hook is correct for one component. It still does not share between components, does not cache, does not refetch when the window gets focus, does not retry, and does not help after a mutation. Every extra feature is more code to own. At this point it is time for a library.

The React docs say that data fetching in effects is allowed, but that you must add cleanup to avoid race conditions, and that it is not the only difficulty. You also have to think about caching (so Back shows the old screen at once), fetching on the server (so the first HTML already has the content), and network waterfalls. I read this in the "You Might Not Need an Effect" page. They suggest your framework's data fetching or a client cache library when you can.

TanStack Query: a cache for server state

TanStack Query (the package is @tanstack/react-query, version 5.104.1 when I ran this) is the most used client-side server-state library in React. Its core idea in one sentence: you describe what data you need with a key and a function, and the library handles fetching, caching, sharing, refetching and errors.

Setup is a client and a provider at the top of the app:

JavaScript
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";const queryClient = new QueryClient();function App() {  return (    <QueryClientProvider client={queryClient}>      <Profile id={1} />    </QueryClientProvider>  );}function Profile({ id }) {  const { data, isPending, isError, error } = useQuery({    queryKey: ["user", id],    queryFn: () => fetch(`/api/users/${id}`).then((r) => {      if (!r.ok) throw new Error(`HTTP ${r.status}`);      return r.json();    }),  });  if (isPending) return <p>Loading...</p>;  if (isError) return <p>Failed: {error.message}</p>;  return <h1>{data.name}</h1>;}

Three things to understand:

  1. queryKey is the name of the data in the cache. ["user", 1] and ["user", 2] are two different entries. When the key changes (because id changed), the library switches to the other entry. The race condition is gone, because each answer is stored under its own key and the screen reads the entry for the current key. There is no shared variable that a late answer can overwrite.
  2. queryFn is a function that returns a promise. If it throws (or rejects), the query is in the error state. Note that you still must turn a 404 into a throw yourself. The library cannot know what you consider an error.
  3. The result object has the status. In version 5, isPending means "there is no data yet" and isError means the last attempt failed. There is also isFetching, which is true whenever a request is running, even when you already have old data to show.

The same three components, with a query

I ran the earlier "three components" example again, with useQuery in each and staleTime set to 60 seconds:

TEXT
query: 2 components, fetches: 1query: 3rd component mounted while fresh (staleTime 60s), fetches: 1cache entry status: success dataUpdateCount: 1

Two components mounted together sent one request (deduplication). A third one mounted later, found fresh data in the cache, and sent zero more. The old way sent three. With a user who moves around the app, this difference is large.

Stale, fresh and the defaults

This part trips people up, so read it carefully. From the "Important Defaults" guide:

  • Cached data is treated as stale immediately by default (staleTime is 0).
  • If you set a staleTime, the data counts as fresh for that long, and no refetch happens.
  • Stale queries are refetched in the background when a new component using them mounts, when the window is focused again, and when the network comes back.
  • When no component uses a query any more, it becomes inactive and stays in the cache for 5 minutes (this setting is called gcTime), then it is removed.
  • A failed query is retried 3 times with growing delays before the error shows.
  • Results are "structurally shared": if a refetch returns the same data, you get the same object reference back, so memo and useMemo downstream do not wake up for nothing.

What does that mean in plain words? With defaults, when you open a page you have seen before, you see the old data at once (no spinner), and in the background the library asks the server for a fresh copy and swaps it in. That feels fast. If you do not want so many refetches, raise staleTime.

Trap: staleTime and gcTime are not the same. staleTime answers "how long do I trust this without asking again?". gcTime answers "how long do I keep unused data in memory?". Mixing them up is a very common interview mistake. In older versions gcTime was called cacheTime, so you will see both names online.

Trap: A tutorial says the default retry makes your error page appear "slow". It is true: with 3 retries and growing waits, an error shows after several seconds. For forms and user-triggered actions you often want retry: false. I did that in my own test so the failing state appears at once.

Changing data: mutations and invalidation

Reading is a query. Writing is a mutation. After a successful write, the cached copy is old, so we tell the library to refetch it:

JavaScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";function Todos() {  const queryClient = useQueryClient();  const todos = useQuery({ queryKey: ["todos"], queryFn: fetchTodos });  const add = useMutation({    mutationFn: (title) => postTodo(title),    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),  });  if (todos.isPending) return <p>loading</p>;  return (    <div>      <ul>{todos.data.map((t) => <li key={t}>{t}</li>)}</ul>      <button onClick={() => add.mutate("eggs")}>add</button>    </div>  );}

I ran this with a fake in-memory "server". The list started as ["milk"]. After one click on "add":

TEXT
list shows [ 'milk', 'eggs' ] queryFn runs: 2

The list function ran twice: once at the start, once after the invalidation. Nobody wrote code to say "now refetch and update the screen". We only said which data became old. This pattern, "mutate, then invalidate", is the heart of how people use the library.

There is also a faster-feeling version, optimistic updates: you change the cached list at once, show it, send the request, and roll back if it fails. React 19 has a built-in useOptimistic hook for the same feeling inside forms. Both are a trade: better feel, more code to get the failure case right. Start without it.

What a cache library does NOT do

  • It does not make a slow server fast. It hides repeats.
  • It does not replace client state. A modal being open is not server state. Do not put it in a query.
  • It does not decide your keys. If you forget to put id in the key (queryKey: ["user"] for every user), all users share one cache entry and you have built the race condition back in a new way. Everything the function reads from outside must be in the key. This is the same rule as an effect's dependency array.
  • Do not copy data into useState "to edit it". Then you have two copies, and the second does not update when the first refreshes. Derive from data, or use the data as a starting value for a form on purpose.

Other choices

TanStack Query is not the only tool. The idea is the same in each, so learn the idea:

  • SWR from Vercel is a smaller library with the "show stale, then refresh" idea in its name.
  • RTK Query is part of Redux Toolkit. If your app already uses Redux Toolkit, it is a natural pick. The Redux Toolkit "Getting Started" page I read says it is an optional set of entry points inside the same package.
  • Framework data loading (Next.js, React Router loaders) can fetch on the server or before the component renders. That removes many client waterfalls. This is the best option when you have a framework, and Concurrent React, Suspense and Server Components in Next.js shows the Next.js side.

Suspense and use() in React 19

So far, loading states are handled by an if (isPending) inside each component. React has a second way: let the component say "I am not ready", and let a parent decide what to show. That parent is <Suspense>.

The React 19 function use reads a promise during render:

JavaScript
import { Suspense, use } from "react";function Profile({ userPromise }) {  const user = use(userPromise);     // suspends until the promise resolves  return <p>hello {user.name}</p>;}function App({ userPromise }) {  return (    <Suspense fallback={<p>spinner</p>}>      <Profile userPromise={userPromise} />    </Suspense>  );}

When use meets a promise that is still pending, it makes the component suspend: React stops rendering that part and shows the nearest Suspense fallback. When the promise settles, React tries again, and now use returns the value. If the promise rejects, the nearest error boundary shows its fallback (we build one in lesson 8). That is why you do not write if (loading) or try/catch in the component.

I ran it. The first paint and the paint after the promise finished:

TEXT
first paint: spinnerafter promise: hello Ada

Rules I read in the use reference page, and that you should keep:

  • use can be called inside if and loops (unlike real hooks), but only inside a component or a hook.
  • You cannot use it in a try/catch. Use an error boundary.
  • The promise must be the same object on every render. If you write use(fetch(url)) inside a Client Component, a new promise is created on every render, and the component suspends forever or loops. The promise must be created outside (in a parent, in a cache, or passed from a Server Component).

In my example I kept a Map outside the component so load(5) returns the same promise each time:

JavaScript
const cache = new Map();const load = (id) => {  if (!cache.has(id)) cache.set(id, getUser(id));  return cache.get(id);};

Trap: A hand-made cache like this one never expires, never retries and never clears. It is fine to show the idea. For real apps, let a framework or a library own the cache, because cache invalidation is the hard part.

Testing trap I hit while writing this: my first test for the use() component failed with the message "A component suspended inside an act scope, but the act call was not awaited". When a component suspends, render must be wrapped as await act(async () => { render(<App />); }). Plain render(...) does not wait. I fixed my test, and that is why the final test does that.

Suspense does not remove the need to think

Suspense gives you a clean place for loading UI. It does not solve:

  • Waterfalls. Two nested components that each use() their own promise still run one after another, because the child is not rendered until the parent stops suspending. Start independent promises together, above both.
  • Where to put the boundary. One Suspense around the whole page means one big spinner. Many small ones can make the screen jump around. A good middle: one per independent section.
  • Showing old content while new loads. If the user switches a tab or a filter and the content below suspends, React replaces it with the fallback by default, which flashes a spinner. Wrap the update in a transition (startTransition or useTransition) and React keeps the old content on screen while the new one loads, with isPending true so you can dim it. We use transitions in lesson 7.

TanStack Query also has a useSuspenseQuery hook, so you can get the Suspense style on top of the cache. This is a popular combination: the cache owns the promise, so you avoid the "same promise" rule problem yourself.

A decision table

SituationReach for
Learning, tiny demo, one requestfetch in useEffect, with ok check and cleanup
A few simple reads, no sharingYour own useFetch hook (with abort)
Many components read the same data, you need retry, refetch, mutationTanStack Query, SWR or RTK Query
You have a framework with server renderingFetch on the server or in a loader first. Use a client cache for the rest
You want loading UI in one placeSuspense, with a cache that owns the promise
It is not from a server (modal open, input text)useState. Not a query

Try it

Do these in a scratch Vite project (npm create vite@latest, choose React) or in a test file.

  1. Write a component Profile({ id }) that fetches from a fake function getUser(id) which waits a random time up to one second. Add two buttons that set id to 1 and 2. Click quickly many times. Can you make the heading and the body disagree?
  2. Fix it with an ignore flag. Then fix it a second way with AbortController, passing the signal to a fake function that rejects when aborted.
  3. Wrap your app in <StrictMode> and count how many times the fake getUser is called on first load. Explain the number.
  4. Change the component to use useQuery with queryKey: ["user", id]. Open the page, click 1, 2, 1. How many calls to getUser happen? Set staleTime: 60_000 and repeat.
  5. Break it on purpose: use queryKey: ["user"] for every id. What do you see after clicking 2?
  6. Make getUser(2) throw. Show an error message. Then add retry: false and notice the difference in how fast the error shows.

Answers

  1. Yes. If getUser(1) takes 900 ms and getUser(2) takes 100 ms and you click 1 then 2, the screen ends with the data of user 1 under the heading of user 2. Because the heading comes from props (always current) and the body comes from state set by the late answer.
  2. The ignore flag version declares let ignore = false inside the effect, checks if (!ignore) before setState, and sets ignore = true in the cleanup. The abort version creates new AbortController(), passes ctrl.signal, calls ctrl.abort() in cleanup, and in .catch returns early when error.name === "AbortError".
  3. Two calls on first load in development. React mounts, runs your cleanup, and mounts again on purpose to find missing cleanups. In production there is one call.
  4. Without staleTime: clicking 1, 2, 1 calls getUser for 1, then 2, then (stale, so a background refetch) 1 again, and you see the old data for 1 at once while it refreshes. With staleTime: 60_000: only two calls, because the second visit to 1 is fresh and comes from the cache.
  5. After clicking 2 you keep seeing user 1's data (or it flashes), because both ids share one cache entry. The key must include everything the function depends on.
  6. The query function must throw for the status 404 or 500 (check res.ok). With the default retry of 3 and growing delays the error message appears after several seconds. With retry: false it appears at once.

Interview questions

Why is fetching in useEffect risky, and what can go wrong? (Must know)

I name four problems. First, race conditions: if the dependency changes, an old slow response can arrive after a new one and overwrite it, because nothing cancels the old one. Second, no sharing: each component that mounts sends its own request, and the data is thrown away when it unmounts, so you get duplicate requests and spinners on every visit. Third, waterfalls: a child only starts fetching after the parent has rendered, so independent requests run one after another. Fourth, a lot of boilerplate: loading, error, ok checking, cleanup. I proved the race with a fake API where I finish the answers in the wrong order. The fix is a cleanup that ignores or aborts the old request, or better, a cache library or framework loader.

How do you prevent the race condition in an effect? (Must know)

Return a cleanup function from the effect. The simple way is a local variable let ignore = false, set to true in the cleanup, and checked before calling setState. The stronger way is an AbortController: pass its signal to fetch and call abort() in the cleanup. With abort, the request is really cancelled, and I must not treat the AbortError as a user-facing failure. I would also say that abort does not undo a write on the server, so this is for reads.

Does fetch reject on a 404 or 500? (Trap)

No. fetch only rejects when the request cannot be completed, such as a network error, and in that case it rejects with a TypeError. A 404 or 500 resolves normally with res.ok === false. I always check res.ok and throw myself. I ran this against a local server that always returns 404 to be sure: it printed ok = false status = 404. Axios behaves differently and rejects on 4xx and 5xx by default, which is why people mix this up.

Why does my effect run twice in development? Should I remove StrictMode? (Trap)

In development, StrictMode mounts the component, runs the cleanup, and mounts it again. This checks that my effect can be stopped and restarted safely. It only happens in development. I should not remove StrictMode. I should fix the effect: add the missing cleanup, or move work like "create an order" out of the effect and into an event handler. In my test, one component under StrictMode sent two requests, and with an ignore flag the first answer was harmlessly dropped.

What is the difference between client state and server state? (Must know)

Client state exists only in the browser, like whether a menu is open or what is typed in a box. I am the only source of truth. Server state is a copy of data that lives on a server. It can be old, it needs fetching, it can fail, many components may want it, and someone else can change it. Because of this, server state needs caching, deduplication, refetching and invalidation, and that is what libraries such as TanStack Query do. I use useState or a store for client state and a query library for server state, and I do not copy one into the other.

What is the difference between staleTime and gcTime in TanStack Query? (Senior signal)

staleTime is how long cached data counts as fresh. While it is fresh, no refetch is triggered when a component mounts or the window gets focus. The default is zero, so data is stale at once. gcTime (called cacheTime in older versions) is how long data stays in memory after the last component stopped using it, 5 minutes by default. So staleTime controls refetching and gcTime controls memory. A page you revisit within gcTime shows the old data instantly and refetches in the background if it is stale.

What is a query key and what is the most common mistake with it? (Senior signal)

A query key is the array that names a piece of data in the cache, such as ["user", id]. Anything the query function depends on must be in the key, the same way an effect's dependencies must list everything it reads. The common mistake is to leave out a variable, for example ["user"] for every user, so all users share one entry and show the wrong data. It also matters for invalidation, because invalidateQueries({ queryKey: ["user"] }) matches by prefix, so a good key hierarchy lets me refresh a group at once.

How do you update the screen after a POST or DELETE? (Must know)

I use a mutation, and in its onSuccess I invalidate the related queries, so the cache marks them old and refetches the ones that are on screen. In my test the todo list function ran once at start and once after the mutation, and the new item appeared without any manual state update. For a faster feel I can do an optimistic update: change the cache at once, and roll back in onError. I would mention the trade: more code, and the failure path has to be tested.

What does use() do in React 19, and what are its rules? (Senior signal)

use reads a promise or a context during render. With a promise, the component suspends until it resolves, and the nearest Suspense shows its fallback. A rejected promise goes to the nearest error boundary. Unlike hooks, use can be called in conditions and loops, but only inside a component or hook. It cannot be wrapped in try/catch. And the promise must be stable across renders: creating use(fetch(url)) in a Client Component makes a new promise every render. I pass the promise down from a parent or a Server Component, or take it from a cache.

Does Suspense fix waterfalls? (Trap)

No. If a parent suspends, its children are not rendered, so they cannot start their own requests yet. Two nested components that each read their own promise still run one after another. To avoid it I start independent requests early and together (above both components, in a loader, or on the server) and pass the promises down. Suspense only decides what to show while waiting. It does not change when the work starts.

When would you not use a query library? (Senior signal)

When the app has almost no server data, or when a framework already gives me server-side loading and caching that fits the page, for example loaders or Server Components. Also, for one request in a small script, a custom hook with an abort is fine. A library adds a concept to learn and a cache to reason about. I would also not put non-server data in it, such as a modal being open. I choose it when many components share data and I need retry, refetch, and invalidation, which is the point where hand-written code gets long.

Key takeaways

  • Server state is a copy that can be late, old, shared or missing. Treat it differently from client state.
  • fetch does not reject on 404 or 500. Check res.ok and throw yourself.
  • Fetching in an effect without a cleanup has a real race: I forced it and the screen showed user 1 data under user 2.
  • An ignore flag drops the late answer. AbortController cancels the request. An aborted fetch rejects with AbortError, which is not a real error.
  • StrictMode runs effects twice in development on purpose. In my test one component sent two requests.
  • A query cache fixed duplicates: three components sent three requests by hand, and one request with TanStack Query.
  • Everything a query reads must be in its key. Remember staleTime (how long it is fresh) and gcTime (how long unused data stays).
  • After a mutation, invalidate the related queries so the screen refetches.
  • use(promise) with Suspense moves loading and error UI out of the component. The promise must be stable, and Suspense does not remove waterfalls.

What's next

Next: Context, State Management and Choosing Where State Lives. Now that server data has its own home (a cache), the rest of the state is much smaller. You will see how context can cause extra renders (with real counts), how Zustand and Redux Toolkit differ, and a simple table to decide where each piece of state should live. To practise now, do exercises 1 to 4 above and write down how many requests each version sends.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series