How to Fetch API Data Safely in React: Loading, Errors and Race Conditions

Fix the 4 hidden bugs in fetch-in-useEffect: loading and error states, response.ok, AbortController and race conditions.

By CrunchCubeUpdated 21 October 20266 min read

Here's how most React tutorials fetch data:

function User({ id }) {
  const [user, setUser] = useState(null);
 
  useEffect(() => {
    fetch(`/api/users/${id}`)
      .then((res) => res.json())
      .then(setUser);
  }, [id]);
 
  return <h2>{user.name}</h2>;
}

Short and readable, and it has four bugs. Each one shows up in production, usually on a slow phone connection, and usually in front of a customer. Let's fix them one at a time.

Bug 1: it crashes before the data arrives

On the first render, user is null, so user.name throws: "Cannot read properties of null". The component needs to know it's loading.

Bug 2: errors vanish

If the request fails, nothing tells the user. They stare at a blank space forever.

Bug 3: fetch doesn't treat 404 or 500 as an error

This one surprises almost everyone. fetch only rejects when the request can't happen at all, for example when the network is down. A 404 or 500 is a successful delivery of a bad answer.

It's like a courier who delivers a letter saying "we're closed". The delivery succeeded; the news is bad. You have to open the letter and check:

const res = await fetch(url);
if (!res.ok) throw new Error(`Request failed: ${res.status}`);

res.ok is true for statuses 200 to 299. Without this check, your code calls res.json() on an error page and fails with a confusing parsing error instead.

Bug 4: the race condition

The user clicks user 1, then quickly user 2. Two requests are now in flight. If the first one is slower, it arrives last and overwrites user 2's data. The screen says "User 2" in the heading and shows user 1's details.

Think of two pizzas ordered five minutes apart. The second order changed your mind, but the first pizza turned up late, and now that's dinner.

The safe version

import { useEffect, useState } from "react";
 
function User({ id }) {
  const [user, setUser] = useState(null);
  const [status, setStatus] = useState("loading"); // "loading" | "success" | "error"
  const [error, setError] = useState(null);
 
  useEffect(() => {
    const controller = new AbortController();
    setStatus("loading");
 
    async function load() {
      try {
        const res = await fetch(`/api/users/${id}`, { signal: controller.signal });
        if (!res.ok) throw new Error(`Request failed: ${res.status}`);
        const data = await res.json();
        setUser(data);
        setStatus("success");
      } catch (err) {
        if (err.name === "AbortError") return; // we cancelled it on purpose
        setError(err);
        setStatus("error");
      }
    }
 
    load();
    return () => controller.abort(); // cancel when id changes or on unmount
  }, [id]);
 
  if (status === "loading") return <p>Loading…</p>;
  if (status === "error") return <p role="alert">Couldn't load this user. {error.message}</p>;
  return <h2>{user.name}</h2>;
}

What changed:

  • A status value decides what to show, so the component never touches data that isn't there yet (bug 1) and always shows something when it fails (bug 2).
  • res.ok check turns 404 and 500 into real errors (bug 3).
  • AbortController cancels the old request when id changes. The cleanup function returned from the effect runs before the next effect, so the slow request for user 1 is aborted and can never overwrite user 2 (bug 4). It also stops updates after the component unmounts.
  • Ignoring AbortError, because a cancelled request isn't a failure the user needs to hear about.

Why one status instead of separate isLoading and isError booleans? Two booleans allow impossible combinations like "loading and error at once". One status value can only be one thing.

Make it reusable

You'll write this pattern for every API call, so put it in a custom hook:

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

Now components stay short:

function User({ id }) {
  const { status, data, error } = useFetch(`/api/users/${id}`);
  if (status === "loading") return <p>Loading…</p>;
  if (status === "error") return <p role="alert">{error.message}</p>;
  return <h2>{data.name}</h2>;
}

"Why does it fetch twice?"

In development, React's StrictMode mounts your component, unmounts it, and mounts it again, on purpose. It's checking that your effect cleans up after itself. With the AbortController cleanup, the first request is cancelled and you'll see it marked "(canceled)" in the browser's Network tab. In production it runs once.

If you see two completed requests in development, your cleanup is missing.

Debugging what the API actually sent

When res.json() fails or the data looks wrong, look at the raw response. Copy it from the Network tab and paste it into the JSON formatter to read it properly and see exactly where invalid JSON breaks.

When to stop writing this yourself

The pattern above is right for learning and for small apps. Real apps soon need more: caching so revisiting a page is instant, retries when the network blips, refetching when the window regains focus, and sharing one request between components.

That's what TanStack Query and SWR do. With TanStack Query the whole thing becomes:

import { useQuery } from "@tanstack/react-query";
 
function User({ id }) {
  const { isPending, isError, data, error } = useQuery({
    queryKey: ["user", id],
    queryFn: async ({ signal }) => {
      const res = await fetch(`/api/users/${id}`, { signal });
      if (!res.ok) throw new Error(`Request failed: ${res.status}`);
      return res.json();
    },
  });
 
  if (isPending) return <p>Loading…</p>;
  if (isError) return <p role="alert">{error.message}</p>;
  return <h2>{data.name}</h2>;
}

The res.ok check is still yours to write, because fetch still doesn't throw on 404. The library handles the rest: caching, deduplication, cancellation and race conditions. If you use a framework like Next.js, also look at its own data-loading features before reaching for effects.

What goes wrong

  • Forgetting the res.ok check. The single most common fetch bug.
  • Setting state from a stale response. Always clean up with AbortController (or an ignore flag) when the input can change.
  • Putting an object in the dependency array. useEffect(..., [options]) with a new object each render loops forever. Depend on primitive values like id or url. More in useEffect dependencies and infinite loops.

In one line: track loading and error with one status, check res.ok because fetch won't, cancel old requests with AbortController in the cleanup, and reach for TanStack Query or SWR once your app needs caching.

Keep reading

New games, tools and tutorials

About one short email a week. No spam, one-click unsubscribe.