Skip to content
React rc effects 4 min read

Fetching in Effects

Fetching data when a component mounts (or when an input changes) is one of the most common reasons people reach for useEffect. Done naively, it leaks state updates, races requests against each other, and swallows errors. Done correctly, it tracks loading and error states explicitly, cleans up in-flight requests with an AbortController, and re-runs only when the request input actually changes. This page shows the full pattern — and explains why a data-fetching library is usually the better long-term answer.

The shape of a correct fetch effect

A robust fetch effect needs four pieces of state in mind: the data, a loading flag, an error, and a way to ignore results from a stale request. Because effect callbacks cannot be async directly (an async function returns a promise, but an effect must return a cleanup function or nothing), you declare an async helper inside the effect and call it immediately.

import { useEffect, useState } from "react";

function UserCard({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    async function loadUser() {
      setLoading(true);
      setError(null);
      try {
        const res = await fetch(
          `https://jsonplaceholder.typicode.com/users/${userId}`,
          { signal: controller.signal }
        );
        if (!res.ok) {
          throw new Error(`HTTP ${res.status}`);
        }
        const data = await res.json();
        setUser(data);
      } catch (err) {
        if (err.name !== "AbortError") {
          setError(err.message);
        }
      } finally {
        setLoading(false);
      }
    }

    loadUser();

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

  if (loading) return <p>Loading…</p>;
  if (error) return <p role="alert">Failed to load: {error}</p>;
  return <h2>{user.name}</h2>;
}

Output:

Loading…
(then once the request resolves)
Leanne Graham

Why the dependency matters

The effect lists [userId] as its dependency. That is the request input — the value the fetch is keyed on. When userId changes, React tears down the previous effect (running the cleanup, which aborts the old request) and starts a fresh one. If you forget userId, the component keeps showing the first user forever; if you list the wrong thing, you fetch too often or not at all. The dependency array should contain exactly the reactive values the request URL or options depend on.

Pass every value the fetch reads to the dependency array. The React lint rule react-hooks/exhaustive-deps will flag omissions — treat its warnings as bugs, not noise.

Cleanup prevents stale updates and races

When userId changes quickly (a user clicking through a list), several requests can be in flight at once. Without cleanup, whichever response arrives last wins — which may not be the one matching the current userId. The AbortController solves this: the cleanup calls controller.abort(), cancelling the previous request before the new one starts. An aborted fetch rejects with an AbortError, which we deliberately ignore so it never reaches setError.

If you cannot use AbortController (for example, a non-fetch data source), use an “ignore” flag instead:

useEffect(() => {
  let ignore = false;

  async function loadUser() {
    const data = await getUserSomehow(userId);
    if (!ignore) setUser(data);
  }

  loadUser();
  return () => {
    ignore = true;
  };
}, [userId]);

The flag does not cancel the network call, but it stops the stale result from updating state — which is enough to fix the race and avoid setting state on an unmounted component.

State-handling options at a glance

ConcernApproachNotes
Loadingloading boolean reset at start of each fetchReset to true so re-fetches show the spinner
Errorstry/catch plus an error stateRe-throw non-OK responses so they hit catch
CancellationAbortController + signalIgnore AbortError in the catch block
Races (no fetch)let ignore = false flagGuards setState, does not cancel I/O
Re-fetch triggerDependency array [input]Lists the values the request is keyed on

You usually want a library

The pattern above is correct, but it only handles one request in one component. Real apps also need caching, deduplication, retries, pagination, background revalidation, and request sharing across components — all of which you would otherwise reimplement by hand. Purpose-built libraries do this for you.

import { useQuery } from "@tanstack/react-query";

function UserCard({ userId }) {
  const { data, isPending, error } = useQuery({
    queryKey: ["user", userId],
    queryFn: async ({ signal }) => {
      const res = await fetch(
        `https://jsonplaceholder.typicode.com/users/${userId}`,
        { signal }
      );
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res.json();
    },
  });

  if (isPending) return <p>Loading…</p>;
  if (error) return <p role="alert">Failed to load: {error.message}</p>;
  return <h2>{data.name}</h2>;
}

If you are building a new app, reach for TanStack Query, SWR, or a framework loader (React Router, Next.js) before hand-rolling effects. Save manual fetch effects for the rare one-off where a dependency is not justified.

Best practices

  • Declare the async helper inside the effect and call it — never make the effect callback itself async.
  • Reset loading to true and clear error at the start of every fetch so re-fetches behave correctly.
  • Always return a cleanup that aborts the request (or flips an ignore flag) to avoid stale updates and races.
  • Ignore AbortError in your catch block so cancellations are not reported as failures.
  • Key the dependency array on the request input, and trust exhaustive-deps to catch omissions.
  • Throw on non-OK HTTP responses; fetch only rejects on network errors, not 4xx/5xx status codes.
  • For anything beyond a trivial one-off, prefer a data-fetching library over manual effects.
Last updated June 14, 2026
Was this helpful?