You might trigger memory leaks and unhandled promise rejections by simply prepending the async keyword directly to the useEffect callback. React strictly requires the cleanup function to be synchronous, meaning returning a Promise instead of a teardown function breaks the component lifecycle.

Pattern Use case
Declared function Standard data fetching, clean readability
IIFE Quick inline anonymous execution
Direct async Never use this. Breaks the component lifecycle.

Why You Cannot Pass Async Functions Directly to useEffect

When you attach an asynchronous operation directly to the hook, it automatically returns a Promise. React expects a standard cleanup function, or nothing, to safely unmount the component. Returning a Promise leaves React with no way to cancel the operation when you navigate away.

Background requests keep running. The browser wastes resources, and your state falls out of sync.

3 Ways to Use Async/Await Inside useEffect

You need to isolate the asynchronous logic from the main hook callback. Three reliable patterns get this done safely.

1. The Declared Function Pattern

Declare the async logic inside the hook and call it immediately. This keeps execution scope contained and prevents unnecessary re-renders.

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

  fetchUserData();
}, []);

Anyone reading your code understands exactly what happens on mount.

2. The IIFE Pattern (Self-Executing Anonymous Function)

Wrap the logic in a self-invoking function. Saves a few lines and executes the moment it gets defined.

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

Use this for highly localized, single-purpose requests where naming adds no clarity.

3. Extracting with useCallback (For Reusable Logic)

Extract the fetching logic outside the hook when multiple components need the same data. Wrap it in useCallback to prevent infinite render loops.

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

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

React tracks the function reference. The hook only triggers when actual dependencies change.

Fixing Race Conditions and Memory Leaks

Network requests take unpredictable time. You might trigger a fetch and navigate away before the response arrives. The component unmounts, but the background request still tries to update state.

This triggers the "state update on an unmounted component" warning. Here is how to prevent it.

The AbortController Method (Recommended)

Attach an AbortSignal to your fetch request. Cancel the active network call inside the cleanup function. This is the current standard for safe data fetching in React.

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') {
        // Request was cancelled, no state update needed
        return;
      }
      setError(error);
    }
  };

  fetchUserData();

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

When the component unmounts, controller.abort() fires immediately. The fetch gets cancelled at the network level, not just ignored in JavaScript.

The Boolean Flag Method (Fallback)

If you need to support environments where AbortController is not available, use a boolean flag instead:

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 request still completes, but the state update is skipped.

AbortController Boolean flag
Cancels network request Yes No
Browser support All modern browsers All browsers
Recommended for Production fetch calls Legacy/non-fetch environments

AbortController is preferred because it terminates bandwidth usage entirely. The boolean flag only prevents the state update after the fact.

Managing Loading, Error, and Success States

Avoid multiple boolean flags like isLoading and isError simultaneously. They create impossible states, like being both successful and failing at the same time.

Use discriminated unions instead. With TypeScript, this forces the compiler to recognize exactly which properties are available at each render phase.

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 IDE gives exact autocomplete suggestions based on the current status. No more guessing whether data exists when isLoading is true.

The Stale Closure Trap: Dependency Array Mistakes

Leaving the dependency array empty while referencing external variables inside the async function creates a stale closure. The function remembers the variable values from the first render and ignores all future updates.

// Bug: userId updates, but fetchUserData still uses the initial value
const [userId, setUserId] = useState(1);

useEffect(() => {
  const fetchUserData = async () => {
    const response = await fetch(`https://api.example.com/user/${userId}`);
    // ...
  };
  fetchUserData();
}, []); // userId is missing from the dependency array

Include every external variable used inside the hook in the dependency array. A properly configured ESLint with eslint-plugin-react-hooks catches these automatically. If your Node.js version is outdated, some ESLint plugins may behave unexpectedly, so keeping Node.js up to date is worth doing before setting up your linting environment.

When NOT to Use useEffect for Data Fetching

Writing manual fetch logic, error handling, and cleanup for every network call adds up fast. For standard data fetching requirements, two libraries eliminate this overhead entirely.

React Query and SWR handle caching, retries, and background synchronization out of the box. You skip the manual cleanup phase and get data that stays fresh across all components without extra wiring.

React 19 also introduced the use hook, which lets you read promises directly during the render phase. Combined with Suspense, it eliminates manual effect synchronization for most basic data fetching scenarios. If you are starting a new project, it is worth evaluating whether use plus Suspense covers your needs before reaching for useEffect.

For simple one-off requests on mount, the declared function pattern with AbortController cleanup remains a solid choice. For anything with caching, pagination, or background refetching, reach for React Query or SWR first.