Slapping an async keyword straight onto your useEffect callback might feel intuitive, but it’s a quick way to introduce sneaky memory leaks and unhandled promise rejections. React expects whatever you return from that hook to be a plain, synchronous cleanup function - nothing else. The second you hand it a Promise instead of a teardown function, you basically pull the rug out from under React's lifecycle tracking.

Pattern When to reach for it
Declared function Your everyday data fetch; readable and super obvious to anyone reading your code
IIFE Quick, one-off inline calls where you don’t need an extra name floating around
Direct async Just don't. It breaks React's cleanup mechanism entirely.

Why You Can't Just Pass an Async Function to useEffect

When you mark that top-level callback as async, JavaScript automatically wraps the whole thing in a Promise. React, on the other hand, is specifically waiting for a cleanup function - or undefined - so it knows how to tidy things up when the component unmounts. If you hand it a Promise, React has zero way to cancel your in-flight tasks when a user clicks away.

That leaves your background requests humming along in the background. Your app burns through memory, and your component state ends up drifting out of sync with reality.

3 Clean Ways to Handle Async/Await Inside useEffect

The secret here is simple: keep your async logic tucked inside the hook rather than making the hook itself async. Here are three straightforward ways to do that without angering React.

1. The Declared Function Pattern

Just define your async helper right inside the effect and invoke it on the next line. This keeps all the moving parts neatly scoped inside the hook and avoids triggering unnecessary re-renders.

JavaScript

code9 lines
useEffect(() => {
  const fetchUserData = async () => {
    const response = await fetch('https://api.example.com/user');
    const data = await response.json();
    setUser(data);
  };

  fetchUserData();
}, []);

It’s about as clear as it gets. Anyone scanning your file instantly knows what runs when the component mounts.

2. The IIFE Pattern (Immediately Invoked Function Expression)

If you'd rather save a couple of lines, you can wrap the logic in an immediately invoked anonymous function. It runs the exact moment the engine parses it.

JavaScript

code7 lines
useEffect(() => {
  (async () => {
    const response = await fetch('https://api.example.com/user');
    const data = await response.json();
    setUser(data);
  })();
}, []);

This works well for self-contained, quick queries where coming up with a separate function name just adds visual clutter.

3. Extracting with useCallback (When You Need Reusability)

If you have fetching logic that needs to be shared across handlers or several components, pull it out of the hook entirely. Just make sure to wrap it in useCallback so you don't accidentally spark an infinite render loop.

JavaScript

code9 lines
const fetchUserData = useCallback(async () => {
  const response = await fetch('https://api.example.com/user');
  const data = await response.json();
  setUser(data);
}, []);

useEffect(() => {
  fetchUserData();
}, [fetchUserData]);

Because useCallback stabilizes the function reference, your effect will only rerun when the actual dependencies shift under the hood.

Taming Race Conditions and Runaway Requests

Network requests are wildcards; they take as long as they feel like taking. If a user hits a fetch call and then immediately navigates to another page, that unmounted component will still be sitting around waiting to receive the payload and update its dead state.

That's the classic culprit behind the infamous "state update on an unmounted component" warning. Here’s the right way to patch it.

The AbortController Method (The Gold Standard)

Toss an AbortSignal into your fetch options and abort the active call inside your cleanup function. This is pretty much the industry-standard approach for rock-solid data fetching in React.

JavaScript

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

  const fetchUserData = async () => {
    try {
      const response = await fetch('https://api.example.com/user', {
        signal: controller.signal
      });
      const data = await response.json();
      setUser(data);
    } catch (error) {
      if (error.name === 'AbortError') {
        // The user navigated away; bail out quietly
        return;
      }
      setError(error);
    }
  };

  fetchUserData();

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

The second your component unmounts, controller.abort() fires. The request actually dies at the browser network layer, instead of downloading everything only to be thrown away in JS.

The Boolean Flag Approach (The Workaround)

If you're dealing with older runtimes or an environment where AbortController isn't an option, a simple local flag works as a decent safety net:

JavaScript

code17 lines
useEffect(() => {
  let isActive = true;

  const fetchUserData = async () => {
    const response = await fetch('https://api.example.com/user');
    const data = await response.json();
    if (isActive) {
      setUser(data);
    }
  };

  fetchUserData();

  return () => {
    isActive = false;
  };
}, []);

The network request will still finish in the background, but at least your component is shielded from setting state after it's gone.

AbortController Boolean flag
Kills the actual network call Yes No
Browser compatibility All modern browsers Everywhere
Best fit for Real-world modern fetch setups Legacy runtimes or third-party client wrappers

Go with AbortController whenever you can - it saves actual bandwidth. A boolean flag is just a polite way of ignoring the response once it finally walks through the door.

Keeping Loading, Error, and Success States Under Control

Try to stay away from juggling loose flags like isLoading and isError at the same time. It's way too easy to end up in impossible situations, like having both isLoading: true and isError: true simultaneously.

A discriminated union solves this cleanly. If you're on TypeScript, this forces your type checker to lock down which fields actually exist at each stage of the request.

TypeScript

code30 lines
type FetchState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

const [state, setState] = useState<FetchState<UserData>>({ status: 'idle' });

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

  setState({ status: 'loading' });

  const fetchUserData = async () => {
    try {
      const response = await fetch('https://api.example.com/user', {
        signal: controller.signal
      });
      const data = await response.json();
      setState({ status: 'success', data });
    } catch (error) {
      if (error.name !== 'AbortError') {
        setState({ status: 'error', error: error as Error });
      }
    }
  };

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

Your editor gives you rock-solid autocompletion based on status. You never have to play guessing games about whether data is defined while your loading spinner is spinning.

The Stale Closure Trap: Watch Those Dependencies

Leaving your dependency array empty while touching external state or props inside your async logic is a classic mistake. Your function ends up trapped in a closure from the initial render, stubbornly clinging to day-one values while the rest of your app moves on.

JavaScript

code10 lines
// Bug: userId changes, but fetchUserData is stuck with whatever it had on mount
const [userId, setUserId] = useState(1);

useEffect(() => {
  const fetchUserData = async () => {
    const response = await fetch(`https://api.example.com/user/${userId}`);
    // ...
  };
  fetchUserData();
}, []); // Missing userId here!

Make sure every single variable your effect touches makes it into that array. A properly tuned ESLint config with eslint-plugin-react-hooks will flag these immediately. If you notice your linter acting up or skipping rules, double-check that your Node.js and dependencies are up to date before pulling your hair out over config files.

When You Really Shouldn't Be Using useEffect at All

Let’s be honest: hand-rolling loading states, error boundaries, and teardown logic for every API call gets exhausting fast. If you're building anything non-trivial, dedicated tools will save you an enormous amount of boilerplate.

Libraries like React Query (TanStack Query) and SWR handle caching, deduplication, retry logic, and background re-fetching right out of the box. You get to skip the manual cleanup headaches altogether, and your UI stays snappy across the board.

On top of that, modern React brings the use hook to the table, letting you read promises directly during render alongside Suspense. If you're kicking off a greenfield project, it’s definitely worth seeing whether use and Suspense cover your data requirements before defaulting back to useEffect.

If it’s just a tiny, self-contained fetch on mount, the declared function with an AbortController does the trick nicely. But the moment you find yourself needing caching, pagination, or optimistic updates, do yourself a favor and bring in React Query or SWR.