Skip to content

Fetching data

A component that shows data from a server doesn't have that data the moment it first renders. It has to ask for it, wait, and then show something once the answer arrives, or if the request fails along the way. This chapter covers the pattern React apps use for that: fetch inside an effect, and hold the result, a loading state, and an error in useState.

Here's a Profile component that fetches a user by URL. Rendering happens first, and reaching out to the server right after is exactly the moment useEffect exists for:

jsx
function Profile({ url }) {
  const [data, setData] = useState(null)
  const [error, setError] = useState(null)

  useEffect(() => {
    let active = true
    setData(null)
    setError(null)
    fetch(url)
      .then(r => {
        if (!r.ok) throw new Error(`Request failed: ${r.status}`)
        return r.json()
      })
      .then(d => { if (active) setData(d) })
      .catch(e => { if (active) setError(e) })
    return () => { active = false }
  }, [url])

  if (error) return <p>Failed to load</p>
  if (!data) return <p>Loading...</p>
  return <h1>{data.name}</h1>
}

If fetch, promises, or .then are still unfamiliar, the JavaScript handbook covers them from the ground up in Async JavaScript; everything here builds directly on that.

useEffect is the hook that runs code after React has rendered a component, rather than during the render itself. You give it a function to run and a dependency array, the list of values that should make it run again when they change, here only url. Profile starts with two pieces of state: data for the result and error for anything that went wrong. At the top of the effect, setData(null) and setError(null) clear out whatever the previous run left behind. That matters for two reasons: without it, a url change would keep showing the old profile instead of "Loading...", since data still holds the last successful result, and a request that succeeds after an earlier one failed would never clear the old error, since nothing ever sets error back to null. Then fetch returns a promise for the response. fetch only rejects that promise on a network failure, so a 404 or 500 still resolves normally as a response object, which is why the first .then checks r.ok and throws before the response ever reaches .json(). Skip that check and a failed request would flow straight into data as if it had succeeded. Once the check passes, .json() reads the body and the result lands in data through setData. If any of this rejects, .catch puts the problem in error instead. Down in the JSX, error and data are checked like any other state: an error renders a message, no data yet renders a loading message, and data renders the real UI.

There's no separate loading variable here. As long as data and error are both still null, the component is between the request going out and something coming back, so if (!data) return <p>Loading...</p> covers that gap on its own. Some components do add a dedicated isLoading boolean for finer control, but deriving loading from state you already have is often simpler, and it's one fewer value to keep in sync.

The active variable inside the effect is a cleanup flag. useEffect can return a function, and React calls that function right before the effect runs again and when the component unmounts. Here, the returned function sets active to false, and both .then and .catch check it before calling setData or setError. Without that check, a request still in flight when url changes, or when the component leaves the screen entirely, would eventually resolve and try to update state that no longer belongs to what's showing. The flag turns a late response into a no-op instead of a bug.

Fetching by hand like this is worth understanding, because it's the pattern almost everything else builds on. In a real app you'll usually reach for a data-fetching library like React Query, or a framework's built-in data loading (Next.js and similar frameworks handle this at the routing layer), rather than write useEffect and a few pieces of state for every request. Those tools handle caching, retries, and the timing issues below for you. The pattern in this chapter is what they're built on.

Race conditions between overlapping requests are the sharp edge worth naming directly. If url changes quickly, someone typing into a search box, or clicking between profiles, the component fires a new request before the previous one has resolved, and network responses don't reliably arrive in the order they were sent. Without the cleanup flag, a slow response to the first request can land after a fast response to the second, overwriting data with stale content for a url you're not even showing anymore. That's exactly what active prevents: each run of the effect closes over its own active variable, so a request from a previous run can never write into the current render's state once its own cleanup has fired.

AbortController is the other tool for this, and it goes further: it cancels the in-flight request itself rather than only ignoring a stale response that arrives later.

jsx
useEffect(() => {
  const controller = new AbortController()
  setData(null)
  setError(null)
  fetch(url, { signal: controller.signal })
    .then(r => {
      if (!r.ok) throw new Error(`Request failed: ${r.status}`)
      return r.json()
    })
    .then(setData)
    .catch(e => { if (e.name !== 'AbortError') setError(e) })
  return () => controller.abort()
}, [url])

This version still needs the same two fixes as the effect above: resetting data and error at the top of the run, so a retry can actually clear a previous failure or a stale profile, and checking r.ok before parsing the body, since fetch treats a 404 or 500 as a completed request rather than a rejection. AbortController only replaces the job the cleanup flag was doing, canceling a request that's no longer wanted, so it doesn't do anything about how state carries over between runs or how fetch treats a failed HTTP response on its own. Both approaches fix the ordering problem, but aborting also saves the network the trouble of finishing a request nobody wants anymore.

The newer direction, as of React 19, is reading async data with the use hook inside a component wrapped in <Suspense>, which lets React handle the pending and loading state at the framework level instead of by hand in every component. use isn't typically used on its own. It's usually paired with a framework or a data library that manages caching and the request lifecycle underneath it. Beyond the basics picks this up along with the rest of the wider data-fetching ecosystem.

JunoFetch in an effect, watch the states The pattern to remember: put the fetch inside useEffect, keep data and error in state, and check them in your JSX to decide what to show. Loading is the moment when both are still empty. The active flag stops a late response from setting state on a component that isn't showing that data anymore.
JunoFetch in an effect, watch the states Fetch inside useEffect, store the result and any error in state, and render off of those directly instead of adding a separate loading boolean if you don't need one. Return a cleanup function that flips an active flag, so a stale response from a previous url or an unmounted component can't overwrite current state. Past a one-off request, reach for React Query or your framework's data loading instead of hand-rolling this everywhere.
JunoFetch in an effect, watch the states Each effect run owns its own active closure, which is what keeps it safe against out-of-order responses when url changes quickly; AbortController does the same job and also cancels the wasted request. Treat raw useEffect fetching as the mechanism worth understanding once. Real apps lean on a data library or the use hook with Suspense for actual requests, both built on exactly this pattern underneath.

Next up: Refs and the DOM, the escape hatch for the jobs that need the DOM node itself.