DEV Community

Cover image for Your React Fetch Requests Don't Care That You Left
Thea
Thea

Posted on

Your React Fetch Requests Don't Care That You Left

I was building a search feature: nothing fancy, just a text input that fetches results from an API as the user types.

It worked fine during testing, but then I noticed something strange. Stale results flashed on screen, and sometimes the UI showed data that didn't match what the user had typed last.
No error. No warning. Just something quietly wrong beneath the surface.

After some digging, I realized the problem wasn't really in my search logic. It was in my assumptions about how React and fetch work together.

This post is what I wish someone had explained to me before I ran into it.

The assumption that breaks everything

When a React component disappears from the screen, React says it has unmounted. And it's natural to assume that everything inside it stops, too. But that's not how JavaScript works.

If you start a fetch() request inside a component, that request doesn't automatically stop when the component unmounts. It continues independently of the component's lifecycle unless you cancel it or the operation finishes on its own.

And when the request finishes, its promise callbacks can still run. React won't bring an unmounted component back to life just because a response arrived, but the work may still have been unnecessary.

The bigger problem happens when the component is still mounted but the data it requested is no longer relevant.

Here's the sequence that causes trouble:

  1. A component starts a fetch request.
  2. The user types something new, or navigates to another view.
  3. React runs the previous effect's cleanup, if there is one.
  4. The old request finishes anyway if it wasn't cancelled.
  5. Its result may overwrite data from a newer request if the code doesn't guard against stale results.

The result: wasted work, and sometimes a UI showing results for a query the user already changed.

Older React versions also warned about state updates on unmounted components. React 18 removed that warning because it often flagged harmless cases, not because every asynchronous operation suddenly started cleaning itself up.

So let's look at what we can actually do about it.

The first fix: ignore obsolete results

The React docs show a simple pattern with an ignore flag:

import { useEffect, useState } from "react";

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

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

    fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
      .then((res) => {
        if (!res.ok) {
          throw new Error("Failed to fetch user");
        }

        return res.json();
      })
      .then((data) => {
        if (!ignore) {
          setUser(data);
        }
      })
      .catch((error) => {
        if (!ignore) {
          console.error(error);
        }
      });

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

  return <p>{user?.name ?? "Loading..."}</p>;
}
Enter fullscreen mode Exit fullscreen mode

When userId changes, React runs the cleanup for the previous effect before starting the next one. The old effect's ignore variable becomes true, so its result won't be applied.

Each effect invocation has its own variable. The new effect gets a fresh ignore = false, while the old one stays ignored.

That distinction matters: you aren't disabling updates for the entire component. You're preventing one particular request from applying a result after its effect has become obsolete.
But here's the thing: the request itself is still running.

The browser may continue downloading a response you're going to throw away. The ignore flag protects your application from using an obsolete result, but it doesn't cancel the underlying request.

The analogy I keep coming back to is ordering a pizza and then deciding you don't want it anymore.

The ignore flag is like telling yourself, "I won't eat it when it arrives."

The delivery driver still shows up. The pizza may already be in the oven. You're simply choosing not to use what arrives.

Wouldn't it be better to cancel the order when you can?

Cancelling for real: AbortController

Browsers give us a way to abort certain ongoing operations, including fetch(). It's called AbortController, and it's been wdely supported for years.

Here’s how it works:

import { useEffect, useState } from "react";

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

  useEffect(() => {
    const controller = new AbortController();
    let ignore = false;

    async function fetchUser() {
      setLoading(true);
      setError(null);

      try {
        const response = await fetch(
          `https://jsonplaceholder.typicode.com/users/${userId}`,
          { signal: controller.signal }
        );

        if (!response.ok) {
          throw new Error("Failed to fetch user");
        }

        const data = await response.json();

        if (!ignore) {
          setUser(data);
          setLoading(false);
        }
      } catch (error) {
        if (ignore || controller.signal.aborted) {
          return;
        }

        setError(error.message);
        setLoading(false);
      }
    }

    fetchUser();

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

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

  return (
    <div>
      <h2>{user?.name}</h2>
      <p>{user?.email}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

A few things are happening here.

  • We create a controller for each effect invocation.
const controller = new AbortController();
Enter fullscreen mode Exit fullscreen mode

This controller belongs to that particular request lifecycle. When userId changes, the next effect gets a new controller.

  • We pass its signal to fetch().
fetch(url, { signal: controller.signal });
Enter fullscreen mode Exit fullscreen mode

The signal connects the request to the controller. Without it, calling controller.abort() wouldn't cancel this fetch.

  • We abort during cleanup.
return () => {
  ignore = true;
  controller.abort();
};
Enter fullscreen mode Exit fullscreen mode

When the effect is cleaned up, we ask the browser to abort the request and mark that effect as obsolete.

  • We handle cancellation separately from real errors.

An aborted fetch normally rejects with an AbortError. That's expected behavior, not necessarily a failure that should be shown to the user.
Notice that the example uses both controller.abort() and ignore.

Why both?

Because cancellation and guarding against obsolete results are related, but they aren't the same thing. The controller handles the abortable operation; the flag prevents this effect from applying results or errors after it becomes obsolete.

Also, aborting a request doesn't undo a state update that has already happened. Cleanup can only affect work that hasn't already completed.
That's the important distinction.

Where it matters most: search race conditions

This is where things get interesting.

Imagine a search input that fires a request whenever the query changes:

  • The user types em: Request A starts.
  • The user types emi: Request B starts.
  • The user types emily: Request C starts.

Requests don't finish in the order they started:

Request A: "em"    -------------------> finishes last
Request B: "emi"   -------------> finishes second
Request C: "emily" ------> finishes first
Enter fullscreen mode Exit fullscreen mode

If you apply every response without checking whether it's still relevant, Request A overwrites the results from Request C. The input says emily, but the UI shows results for em.

That's a race condition.

Here's a complete search component that handles it:

import { useEffect, useState } from "react";

export default function SearchUsers() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    const trimmedQuery = query.trim();

    if (!trimmedQuery) {
      setResults([]);
      setLoading(false);
      setError(null);
      return;
    }

    const controller = new AbortController();
    let ignore = false;

    async function searchUsers() {
      setLoading(true);
      setError(null);

      try {
        const response = await fetch(
          `https://dummyjson.com/users/search?q=${encodeURIComponent(
            trimmedQuery
          )}`,
          { signal: controller.signal }
        );

        if (!response.ok) {
          throw new Error("Search failed");
        }

        const data = await response.json();

        if (!ignore) {
          setResults(data.users);
          setLoading(false);
        }
      } catch (error) {
        if (ignore || controller.signal.aborted) {
          return;
        }

        setError(error.message);
        setLoading(false);
      }
    }

    searchUsers();

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

  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={(event) => setQuery(event.target.value)}
        placeholder="Search users..."
      />

      {loading && <p>Searching...</p>}
      {error && <p>Error: {error}</p>}

      {!loading && !error && results.length === 0 && query.trim() && (
        <p>No users found.</p>
      )}

      <ul>
        {results.map((user) => (
          <li key={user.id}>
            {user.firstName} {user.lastName} ({user.username})
          </li>
        ))}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Every time query changes, React cleans up the previous effect before running the next one. That cleanup aborts the old request and marks its effect as obsolete.

The ignore flag is the extra safeguard: even if an asynchronous operation has already progressed beyond the point where cancellation can help, the obsolete effect won't apply its result.

One detail worth noticing: this component keeps the previous results visible while a new search is loading. That's a deliberate UI choice, not a requirement. If you prefer to clear the results immediately, you can do that when the query changes.

The important thing is to know which data your UI is showing.

Wait, shouldn't I just debounce the input?

You might be thinking, "I'll just wait until the user stops typing before sending a request."

That's a good idea, too. Debouncing reduces the number of requests by waiting for a pause in typing before starting one. It can save unnecessary API calls, especially when users type quickly.

But debouncing alone doesn't cancel a request that has already started. An earlier request can still finish after a newer one.
Cancellation and debouncing are teammates, not competitors.
Debounce to reduce unnecessary requests. Abort obsolete work. Guard against stale results.

Each technique addresses a different part of the problem.

Ignore flag vs AbortController

Here's the practical difference:

ignore flag AbortController
Prevents obsolete effect results from being applied Yes, if checked consistently Not by itself in every asynchronous workflow
Cancels an in-flight fetch() No Yes, when the signal is passed
Can guard non-abortable async work Yes No, not on its own
Can reduce unnecessary response downloading No Often, if the request is aborted in time
Guarantees the server stops processing No No
Requires the operation to support an abort signal No Yes

About that last row: aborting cancels the request on the client, but it doesn't guarantee the server stops processing it. If the server already received the request, it may finish the work anyway. Sometimes the pizza is already in the oven.

And if the request has side effects, like placing an order or making a payment, client-side cancellation is not a substitute for proper server-side handling.

One more thing: Strict Mode

If you see an effect run, clean up, and run again in development, don't panic.

When Strict Mode is enabled at the root, React deliberately runs an extra setup-and-cleanup cycle for effects on initial mount in development. It checks whether your cleanup properly undoes your setup.

With AbortController, you may see one request get cancelled and another succeed in your Network tab. That's expected in this situation.

Don't hide the extra request by disabling Strict Mode or forcing the effect to run only once. Make sure your setup and cleanup work correctly together. The extra development-only cycle doesn't happen in production.

When should you use this?

Whenever a request can become irrelevant before it finishes:

  • Prop-driven fetches: userId changes, so the previous user's data no longer matters.
  • Search and autocomplete: the query changes while earlier requests are still running.
  • Pagination and filters: the user jumps to another page or changes a filter.
  • Tabs and modals: the user closes the view before the request completes.

For a fetch inside an effect, an abort signal is usually all you need to cancel obsolete in-flight work. For async work that can't be aborted, a flag can prevent its result from being applied after cleanup. For mutations, remember that the server may still act on the request.

What if you use TanStack Query or SWR?

Then you may never write an AbortController yourself.
These libraries handle caching, request deduplication, and keeping results tied to the right query. TanStack Query even gives your query function an AbortSignal that you can pass to fetch, so the request can be cancelled.

The React docs also suggest using your framework's data fetching or a client-side cache instead of fetching everything by hand in effects. There's a good section on this in You Might Not Need an Effect.

But understanding the mechanism still pays off. It helps you debug strange behavior, and it tells you what your tools are doing for you.

Final thoughts

When I first hit this bug, I thought my search logic was broken. What I really needed was a better picture of how async work relates to a component's lifecycle.

A component can disappear. A query can change. A request can become irrelevant. None of that cancels a promise, and none of it undoes work that already happened.

The ignore flag prevents an obsolete result from being applied. AbortController cancels supported in-flight work, which also prevents an unfinished fetch from delivering a stale result in these examples.

And that's what I want to remember: good cleanup isn't just about stopping work. It's about making sure work that no longer belongs to the current UI can't interfere with it.

Because the request doesn't know you've moved on.

Your code has to tell it.

Top comments (0)