Cancelling Requests with AbortController

When a component fetches data, the request takes time to resolve — but the component doesn’t stand still while it waits. The user might navigate away, type a new search query, or the component might simply unmount before the response arrives. If you don’t cancel the old request, it can resolve later and overwrite newer state with stale data, or try to update a component that no longer exists. AbortController is the browser API that lets you cancel a fetch request in flight, and pairing it with the useEffect cleanup function is the standard React pattern for doing this correctly.

Overview / How It Works

AbortController is a built-in browser (and Node.js) API, not something React provides — but React’s component lifecycle is exactly why you need it. Every time a component re-renders because a dependency changes (like a search query or a route param), a useEffect with that dependency in its array runs again, potentially firing a new fetch call while an older one is still pending. Without cancellation, both requests race, and whichever resolves last wins — even if it’s the outdated one.

An AbortController instance has a signal property (an AbortSignal) and an abort() method. You pass controller.signal as the signal option to fetch. Calling controller.abort() immediately rejects the pending fetch promise with a DOMException named "AbortError" (its .name property equals "AbortError"). Your code can check for that specific error and ignore it, since an aborted request isn’t a real failure — it’s an intentional cancellation.

In React, the natural place to create and abort a controller is inside useEffect. Every effect can return a cleanup function, and React guarantees that cleanup runs before the effect re-runs (when a dependency changes) and when the component unmounts. That’s precisely the moment you want to cancel: the old fetch is no longer relevant because either the inputs changed or the component is gone. This ties the request’s lifetime directly to the effect’s lifetime, so you never have a fetch outliving the render that started it.

This also protects against the classic "setting state after unmount" problem. In older React code you’ll sometimes see an isMounted boolean ref used to guard setState calls after an async operation resolves. AbortController is the more correct fix: instead of just suppressing the state update, it tells the browser to actually stop the network request, saving bandwidth and server load, not merely papering over a warning.

Syntax

The general pattern looks like this:

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

  async function loadData() {
    try {
      const response = await fetch("https://api.example.com/data", {
        signal: controller.signal,
      });
      const data = await response.json();
      setData(data);
    } catch (error) {
      if (error.name === "AbortError") {
        console.log("Fetch aborted");
      } else {
        setError(error.message);
      }
    }
  }

  loadData();

  return () => controller.abort();
}, []);
Part Purpose
new AbortController() Creates a fresh controller for this effect run; a new one is needed every time the effect re-runs.
controller.signal The token passed to fetch‘s signal option so the request can be tied to this controller.
controller.abort() Cancels the request; called from the effect’s cleanup function.
error.name === "AbortError" Distinguishes an intentional cancellation from a genuine network or server error.

Examples

Example 1: Cancelling a fetch when props change or the component unmounts

import { useState, useEffect } from "react";

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

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    setError(null);

    fetch(`https://jsonplaceholder.typicode.com/users/${userId}`, {
      signal: controller.signal,
    })
      .then((response) => {
        if (!response.ok) {
          throw new Error(`Request failed with status ${response.status}`);
        }
        return response.json();
      })
      .then((data) => {
        setUser(data);
        setLoading(false);
      })
      .catch((err) => {
        if (err.name === "AbortError") {
          return;
        }
        setError(err.message);
        setLoading(false);
      });

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

  if (loading) return <p>Loading user...</p>;
  if (error) return <p>Error: {error}</p>;

  return (
    <div>
      <h3>{user.name}</h3>
      <p>{user.email}</p>
    </div>
  );
}

export default UserProfile;

Output:

Renders "Loading user..." briefly, then the fetched user's name and email once the request resolves. If `userId` changes quickly (e.g. clicking through a list), the in-flight request for the previous `userId` is aborted before the new one starts, so only the final user's data is ever shown.

This component fetches a user whenever userId changes. Because the effect’s cleanup calls controller.abort(), switching to a new userId before the old request finishes cancels it — the catch block sees an AbortError and silently returns instead of setting an error or turning off the loading spinner for a request that no longer matters.

Example 2: Fixing a race condition in a search box

import { useState, useEffect } from "react";

function SearchBox() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (query.trim() === "") {
      setResults([]);
      return;
    }

    const controller = new AbortController();

    fetch(`https://api.example.com/search?q=${encodeURIComponent(query)}`, {
      signal: controller.signal,
    })
      .then((response) => response.json())
      .then((data) => setResults(data.results))
      .catch((error) => {
        if (error.name !== "AbortError") {
          console.error("Search failed:", error);
        }
      });

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

  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      <ul>
        {results.map((result) => (
          <li key={result.id}>{result.title}</li>
        ))}
      </ul>
    </div>
  );
}

export default SearchBox;

Output:

Renders a text input and a list of result titles. Typing "rea" then quickly "react" cancels the request for "rea" before it resolves, so the list only ever reflects the latest query — it never briefly flashes stale results for a query the user already moved past.

This is the case where AbortController earns its keep. Every keystroke changes query, re-running the effect and firing a new request. Without cancellation, a slow response for an early keystroke could arrive after a faster response for a later keystroke and overwrite it with outdated results. Aborting the previous request as soon as a new one starts eliminates that race entirely.

Example 3: A reusable useFetch custom hook

import { useState, useEffect } from "react";

function useFetch(url) {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);

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

    async function fetchData() {
      setLoading(true);
      setError(null);
      try {
        const response = await fetch(url, { signal: controller.signal });
        if (!response.ok) {
          throw new Error(`HTTP error ${response.status}`);
        }
        const json = await response.json();
        setData(json);
      } catch (err) {
        if (err.name !== "AbortError") {
          setError(err.message);
        }
      } finally {
        if (!controller.signal.aborted) {
          setLoading(false);
        }
      }
    }

    fetchData();

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

  return { data, error, loading };
}

function Post({ postId }) {
  const { data: post, error, loading } = useFetch(
    `https://jsonplaceholder.typicode.com/posts/${postId}`
  );

  if (loading) return <p>Loading post...</p>;
  if (error) return <p>Error: {error}</p>;

  return (
    <article>
      <h3>{post.title}</h3>
      <p>{post.body}</p>
    </article>
  );
}

export default Post;

Output:

Renders "Loading post..." then the post's title and body. Changing `postId` cancels the previous fetch and shows the loading state again for the new id, without ever briefly displaying the old post's stale content under the new id.

Wrapping the pattern in a useFetch hook keeps every component that needs data fetching free of boilerplate. The finally block checks controller.signal.aborted before flipping loading off, so an aborted request’s cleanup can’t accidentally mark a newer request as "done loading" when it was really the old one being torn down.

How It Works Step by Step

On mount: the effect runs, creates an AbortController, and starts the fetch with controller.signal attached. The component renders its loading state while the promise is pending.

On a dependency change (e.g. userId or query updates): React first runs the previous effect’s cleanup function, calling controller.abort() on the old controller. This immediately rejects the old fetch promise with an AbortError. React then runs the effect again, creating a brand-new AbortController for the new request. The old request’s .catch() handler still fires, but recognizes the AbortError and skips setting error/loading state.

On unmount: React runs the cleanup function one final time, aborting whatever request is still in flight. Because the component is gone, this prevents the classic "Can’t perform a React state update on an unmounted component" situation — the promise still rejects, but there’s no live component left to call setState on incorrectly, and the browser stops wasting bandwidth on a response nobody needs.

Common Mistakes

Mistake 1: Not distinguishing AbortError from a real error. If you don’t check error.name, cancelling a request looks identical to it failing — every keystroke in a search box would flash an error message.

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

  fetch("/api/data", { signal: controller.signal })
    .then((res) => res.json())
    .then((data) => setData(data))
    .catch((error) => {
      setError(error.message);
    });

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

Here, aborting the request throws an AbortError, which lands in .catch() and calls setError — so the UI briefly shows an error every time the user types, even though nothing actually went wrong. Fix it by checking error.name before setting error state, as shown in the corrected examples above.

Mistake 2: Skipping AbortController entirely and relying only on the dependency array.

useEffect(() => {
  fetch(`/api/search?q=${query}`)
    .then((res) => res.json())
    .then((data) => setResults(data));
}, [query]);

This effect re-fires correctly on every query change, but never cancels the previous request. If an earlier request happens to resolve after a later one (a common occurrence with variable network latency), its .then() still runs and overwrites results with stale data — a race condition the dependency array alone cannot prevent. Always create a new AbortController per effect run and abort it in the cleanup function.

Best Practices

  • Create a new AbortController inside the effect, not outside it — each effect run needs its own controller tied to its own request.
  • Always return () => controller.abort() from effects that start a fetch, even if you think the component rarely unmounts quickly — dependency changes trigger cleanup just as often as unmounts.
  • Check error.name === "AbortError" in every catch block that could receive a cancelled request, and skip state updates for it.
  • Don’t rely on an isMounted ref as a substitute for cancellation — it silences the warning but doesn’t actually stop the network request from completing.
  • Extract the fetch-and-abort pattern into a custom hook (like useFetch) once you’re repeating it across multiple components.
  • Remember some environments (older browsers, some non-fetch libraries like older Axios versions) need a polyfill or different cancellation token — AbortController is standard in all modern browsers and Node 18+.

Practice Exercises

  • Build a WeatherWidget component that fetches weather for a city name typed into an input, debounced-free (fire a request on every keystroke), and uses AbortController so only the last request’s result is ever displayed.
  • Take the useFetch hook from Example 3 and add a refetch function it returns, allowing a component to manually re-trigger the request (e.g. from a "Retry" button) while still properly aborting any request that’s still pending.
  • Modify UserProfile from Example 1 to also abort the fetch if the user clicks a "Cancel" button while loading, in addition to aborting on unmount or userId change.

Summary

  • AbortController is a browser API for cancelling an in-flight fetch request via its signal property and abort() method.
  • Create the controller inside useEffect, pass controller.signal to fetch, and call controller.abort() in the effect’s cleanup function.
  • React runs cleanup both when a dependency changes and when the component unmounts — both are moments where an old request should be cancelled.
  • Always check error.name === "AbortError" to distinguish an intentional cancellation from a genuine fetch failure.
  • Cancelling requests prevents race conditions (stale responses overwriting fresh ones) and avoids wasted network usage from abandoned requests.