skip to content

In React, how do you wire an AbortController into a useEffect that calls fetch, and why must the controller be created inside the effect body rather than held in a ref?

level: middleimportance: must knowfreq 68%

answer

  1. cancellation needs a handle
  2. created per run, not per component
  3. the signal goes into fetch's options
  4. abort belongs in the returned cleanup

basics

~20 s

Create a fresh AbortController inside the effect body, pass controller.signal as fetch's signal option, and return a cleanup that calls controller.abort(). A controller is single-use: once aborted its signal stays aborted, so a shared one poisons every later request.

solid answer

~50 s

Create the controller inside the effect body so each run gets its own, pass its signal into `fetch`, and abort it from the cleanup the effect returns: `const controller = new AbortController()`, then `fetch(url, { signal: controller.signal })`, then `return () => controller.abort()`. React runs that cleanup before the effect re-runs on a dependency change and once at unmount, so the request belonging to the previous run is cancelled before the next one starts. The controller must live inside the effect because it is single-use — once aborted, its signal stays aborted permanently, so a controller hoisted into a ref or module scope would make every request after the first teardown reject the instant it is created. One more piece: aborting rejects the fetch promise, so the catch has to recognise that rejection instead of treating it as a failed request.

code

javascript · 22 lines
javascript
import { useEffect, useState } from 'react';

export function useJson(url) {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

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

    fetch(url, { signal: controller.signal })
      .then((res) => res.json())
      .then(setData)
      .catch((err) => {
        if (err.name === 'AbortError') return;
        setError(err);
      });

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

  return { data, error };
}

go deeper

for a junior

Be able to write the three lines from memory: create the controller in the effect, pass controller.signal to fetch, return a cleanup that calls controller.abort().

for a middle

Explain why the controller is created per effect run — it is single-use, and a reused signal stays aborted — and why aborting produces a rejection your catch must recognise rather than report.

for a senior

Show the production reasoning: what a cancelled request actually frees, why the same signal must be threaded through your own request wrappers, and why abort is not a rollback for a mutation.

for a principal

Own the platform decision — whether app code writes this wiring at all, or whether a shared data layer owns cancellation so that no product engineer can forget it and no wrapper can silently drop a signal.

## The wiring, in full ```js useEffect(() => { const controller = new AbortController(); fetch(url, { signal: controller.signal }) .then((res) => res.json()) .then(setData) .catch((err) => { if (err.name === 'AbortError') return; setError(err); }); return () => controller.abort(); }, [url]); ``` Three moving parts: a controller created per effect run, its `signal` handed to `fetch` through the options object, and `abort()` called from the function the effect returns. Everything else is detail. ## Why the controller belongs inside the effect body An `AbortController` is a one-shot device. Calling `abort()` flips its signal permanently — `signal.aborted` is `true` from then on and there is no reset. Anything you subsequently pass that signal to is cancelled the moment it starts, because a request handed an already-aborted signal does not run at all. That is why the controller has to be created fresh in each effect run and cannot be stashed somewhere that survives across runs: ```js // broken const controllerRef = useRef(new AbortController()); useEffect(() => { fetch(url, { signal: controllerRef.current.signal }).then(/* ... */); return () => controllerRef.current.abort(); }, [url]); ``` The first run works. Its cleanup aborts the ref's controller. The second run passes that same, now-permanently-aborted signal to `fetch`, so the request never leaves — and every run after it fails the same way. The bug presents as "data loads once and then the screen is stuck empty", which is a long way from where the mistake is. The same argument rules out module-scope controllers, which would additionally let two mounted components abort each other's requests. ## Creating it in the effect, not in render The controller also does not belong in the component body. Render runs on every re-render, so a controller created there is a new object each time while the request in flight still holds the old signal — the cleanup would abort a controller nothing is listening to. Effect body is the right home because effect body and cleanup are the two halves of one run, closing over the same `controller` binding. ## Cleanup timing is what makes this correct React calls the returned function before re-running the same effect and once at unmount. Both cases matter here. At unmount you are cancelling work whose result has nowhere to go. On a dependency change — `url` changed — you are cancelling the request for the old `url` before firing the one for the new `url`, so a component that would otherwise accumulate one in-flight request per keystroke keeps at most one alive. ## The rejection you must handle Aborting does not make the promise vanish; it rejects it. If nothing catches that rejection you get an unhandled rejection in the console every time the user navigates away mid-request, and if the catch is unconditional you paint a failure state for a request you deliberately cancelled. So the catch has to recognise the abort — the rejection reports itself with the name `AbortError` — and return without touching state. With `async`/`await` the shape is the same; the `await` throws and the guard goes at the top of the `catch`: ```js try { const res = await fetch(url, { signal: controller.signal }); setData(await res.json()); } catch (err) { if (err.name === 'AbortError') return; setError(err); } ``` Note that the abort also cuts off body reading, so `res.json()` can reject with the same error — one guard covers both. ## What abort does and does not buy you What it does: stops the browser waiting on the response, closes the connection so the browser's per-host connection budget is freed for requests the user is actually waiting on, stops the response body from being downloaded and parsed, and rejects the promise so your code stops. What it does not: undo anything. The request may already have reached the server and been fully processed — abort is a client-side cancellation, not a rollback. That is why aborting a `GET` is routine and aborting a `POST` that creates something is not a way to "cancel the write"; if a mutation needs to be reversible, that is a server-side concern, not a signal. ## Passing the signal onward When the effect calls a wrapper of your own — `loadUser(id, { signal })` — the signal has to be threaded all the way down to the `fetch` at the bottom. A wrapper that accepts a signal and quietly ignores it is worse than one that never accepted it, because the call site looks cancellable and is not, and the effect's cleanup becomes a lie.

  • What happens if you pass an already-aborted signal to fetch?
    The request never goes out. `fetch` checks the signal before starting and rejects immediately with the abort error, so you see an instant rejection and no network entry at all. That is exactly the failure mode of a controller reused across effect runs — the second and every later request dies before it is sent, while the code reads as though it is fetching normally.
  • Your effect calls a helper like loadUser(id) rather than fetch directly. How does cancellation survive that?
    The helper has to take the signal as a parameter and pass it down to the `fetch` it performs — `loadUser(id, { signal: controller.signal })`. Cancellation only works if the signal reaches the actual request, so a wrapper that accepts a signal and ignores it silently breaks every caller's cleanup while looking correct at the call site.
  • Does aborting a POST mean the server never applied it?
    No. Abort is client-side: it stops the browser waiting and frees the connection, but the request may already have arrived and been processed in full. Treat abort as "I no longer want the answer", never as a rollback. If a mutation genuinely needs to be undoable, that requires a server-side mechanism such as an idempotency key plus an explicit compensating call.

An AbortController is a fuse, not a switch: you can blow it once, and after that it conducts nothing. Every effect run needs its own fuse.

saying these in an interview costs you the question

  • Keeping one AbortController per component and reusing it
  • Calling abort() in the then handler rather than in cleanup
  • Passing the controller itself to fetch instead of its signal
  • Assuming abort() means the server never saw the request
  • Believing abort cancels the promise, so no rejection occurs

context