Back to React
React
medium
mid

How would you use useEffect to trigger an API request and manage its cleanup correctly?

Fetch inside useEffect with the right dependency array; in the cleanup function, abort the request (AbortController) or set an `ignore` flag so a stale/late response can't update an unmounted or superseded component. Handle loading/error states; consider a query library for real apps.

5 min read·~10 min to think through

Fetching in useEffect is straightforward; cleanup is the part interviewers actually grade — it's what prevents stale-response and unmount bugs.

The full pattern

jsx
function User({ userId }) {
  const [state, setState] = useState({ status: "loading", data: null, error: null });

  useEffect(() => {
    const controller = new AbortController();
    setState({ status: "loading", data: null, error: null });

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(res.statusText);
        return res.json();
      })
      .then((data) => setState({ status: "success", data, error: null }))
      .catch((err) => {
        if (err.name === "AbortError") return;          // expected — ignore
        setState({ status: "error", data: null, error: err });
      });

    return () => controller.abort();                     // CLEANUP
  }, [userId]);                                          // re-run when userId changes
  // ...
}

The three things being tested

1. The dependency array. List every value the request depends on (userId). Omit it → fetches every render (infinite loop risk). Empty when it shouldn't be → stale data when the input changes.

2. Cleanup — the core of the question. The cleanup runs before the next effect run and on unmount. Two ways to make stale responses harmless:

  • AbortController (preferred) — actually cancels the request.
  • ignore flag — if you can't abort:
js
useEffect(() => {
  let ignore = false;
  fetchData().then((d) => { if (!ignore) setState(...); });
  return () => { ignore = true; };
}, [userId]);

Without cleanup you get two real bugs: setState on an unmounted component, and the race condition where a slow response for an old userId resolves after a fast response for the new one and overwrites it.

3. UI states — loading / error / success, not just the happy path.

What to also say

For real apps, don't hand-roll thisReact Query/SWR handle the effect, cleanup, caching, deduping, and race conditions for you. The manual version is the interview exercise; the production answer is a query library.

The framing

"I fetch inside useEffect with a dependency array listing everything the request depends on. The graded part is cleanup: I return a function that calls controller.abort() — or flips an ignore flag — so when the component unmounts or the dependency changes before the response lands, a stale response can't update the component. That kills both the setState-after-unmount warning and the out-of-order-response race. I also model loading/error/success states — and in a real app I'd reach for React Query so I'm not hand-rolling any of this."

Follow-up questions

  • What race condition does the cleanup prevent?
  • AbortController vs an `ignore` flag — when would you use each?
  • What goes wrong if the dependency array is missing or wrong?
  • Why would you use React Query instead of this pattern?

Common mistakes

  • No cleanup — setState-after-unmount and out-of-order responses.
  • Missing or wrong dependency array — infinite fetch loop or stale data.
  • Not handling the error and loading states.
  • Making the effect callback itself async (it must return a cleanup fn, not a promise).

Performance considerations

  • Cancelling superseded requests avoids wasted parsing and discarded renders. A wrong dependency array can cause an infinite fetch loop — the most damaging perf bug here.

Edge cases

  • Dependency changes rapidly (search input) — each run aborts the previous.
  • Component unmounts before the response arrives.
  • Request succeeds but the component already moved on.
  • Non-OK HTTP status — fetch doesn't reject on 404/500, you must check res.ok.

Real-world examples

  • Loading a detail view when the selected id changes.
  • Search results that must not show a stale query's response.

Senior engineer discussion

Seniors get the dependency array right, implement cleanup with AbortController (or an ignore flag), explain both bugs cleanup prevents, model all UI states, and note that production code should use a query library rather than this hand-rolled effect.

Related questions