Skip to main content

Data and Errors

Fetching Data

Written by Published

Fetching is an effect, and it needs three things almost everyone forgets first time: a loading state, an error state, and a way to ignore a response that arrived too late.

The full shape

A request has three outcomes, and the UI has to say something for each. Rendering nothing while loading and crashing on failure is the default if you only handle success.

Loading, error, data

jsx

function User({ id }) {
  const [status, setStatus] = React.useState("loading")
  const [user, setUser] = React.useState(null)
  const [error, setError] = React.useState(null)

  React.useEffect(() => {
    const controller = new AbortController()
    setStatus("loading")

    fetch(`https://jsonplaceholder.typicode.com/users/${id}`, {
      signal: controller.signal,
    })
      .then((response) => {
        // fetch only rejects on network failure — a 404 still "succeeds".
        if (!response.ok) throw new Error(`HTTP ${response.status}`)
        return response.json()
      })
      .then((data) => {
        setUser(data)
        setStatus("done")
      })
      .catch((err) => {
        if (err.name === "AbortError") return   // we cancelled it on purpose
        setError(err.message)
        setStatus("error")
      })

    return () => controller.abort()
  }, [id])

  if (status === "loading") return <p>Loading…</p>
  if (status === "error") return <p style={{ color: "#b91c1c" }}>Failed: {error}</p>

  return (
    <div>
      <strong>{user.name}</strong>
      <p style={{ margin: 0, color: "#475569" }}>{user.email}</p>
    </div>
  )
}

function Demo() {
  const [id, setId] = React.useState(1)
  return (
    <div>
      <button onClick={() => setId((n) => (n % 3) + 1)}>Next user (id {id})</button>
      <div style={{ marginTop: 10 }}><User id={id} /></div>
    </div>
  )
}

const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(<Demo />)

fetch does not reject on 404

This surprises everyone once. fetch rejects only when the request could not be made at all - no network, DNS failure, CORS. A 404 or a 500 is a successful request that returned an error status.

So response.ok has to be checked by hand. Without it, a 500 page gets parsed as JSON and the failure surfaces later as a confusing render error rather than as "the server said no".

Cancelling with AbortController

The cleanup calls controller.abort(), which cancels the request outright. That is better than the ignore flag from the cleanup chapter - the browser stops waiting for a response nobody wants.

An aborted request rejects with an AbortError, which is why the catch checks for it first. Without that check, every cancelled request would show the user an error that never actually happened.

Async functions and effects

An effect may not be async. React expects its return value to be a cleanup function, and an async function returns a promise - so React would try to call a promise as cleanup.

Not allowed

jsx

// Wrong: the effect returns a promise, not a cleanup function.
useEffect(async () => {
  const data = await load()
  setData(data)
}, [])

Either use .then as above, or declare an async function inside the effect and call it. Both are fine; the constraint is only on the effect itself.

When to stop hand-rolling this

Everything above is one request in one component. Real apps also want caching, retries, deduplicated requests, refetching when a window regains focus, and shared data between components - and each of those is harder than it sounds.

  • Always handle loading, error and success.
  • Check response.ok; fetch will not do it for you.
  • Abort in the cleanup, and ignore AbortError.
  • The effect itself cannot be async.
  • Once you need caching or sharing, use a data library rather than growing this.