How to Use Async Functions Inside useEffect

useEffect does not accept an async function directly. Declare an async function inside the Effect, call it, and guard against stale responses in the cleanup.

6 min read

The useEffect Hook does not accept an async function directly, because its setup must return either nothing or a cleanup function. The setup may return undefined or a function, never a Promise. The pattern for async functions inside useEffect is to declare one, call it, and guard against stale responses in the cleanup.

Why the setup cannot be async

React expects the Effect setup to return undefined or a cleanup function. An async function always returns a Promise, which React cannot use as cleanup.

App.jsxApp.jsx
useEffect(async () => {
  const data = await fetchData();
  setData(data);
}, []);

This looks convenient but it is wrong. The Promise becomes the Effect's return value, React has no cleanup to run, and any state update after the component unmounts still fires. React also needs to run the setup and cleanup as a pair, and an async function hides the cleanup entirely.

Declare, call, and clean up

Move the async work into a function declared inside the Effect. Call it, and return a cleanup that marks the result as stale. The outer setup stays synchronous and returns the cleanup, while all await work moves into load.

App.jsxApp.jsx
import { useEffect, useState } from "react";
function Profile({ userId }) {
  const [profile, setProfile] = useState(null);
  useEffect(() => {
    let ignore = false;
    (async () => {
      const data = await (await fetch(`/api/users/${userId}`)).json();
      if (!ignore) setProfile(data);
    })();
    return () => { ignore = true; };
  }, [userId]);
  return <p>{profile?.name ?? "Loading..."}</p>;
}

The page shows a loading label, then the fetched name. When userId changes, the cleanup flips the old Effect's ignore flag, so a slow response from the previous user is dropped instead of overwriting the new one. The ignore flag matters because the outer setup runs again whenever userId changes.

The same pattern works for any async source: a database read, a file, or a delayed task. Declare the async function, call it, and flip the flag in the cleanup. The shape stays the same across all of them.

Handle errors inside the async function

A rejected Promise inside an async function throws at the await. Catch it so the failure becomes state instead of an unhandled rejection. The catch runs in the same closure, so it can check the same ignore flag and keep a stale error out of state.

App.jsxApp.jsx
async function load() {
  try {
    const data = await fetchData(userId);
    if (!ignore) setData(data);
  } catch (error) {
    if (!ignore) setError(error);
  }
}

The UI can now render an error message alongside the loading and success states. The same ignore check keeps an error from a stale request out of state. Without try/catch, the rejected Promise becomes an unhandled rejection and the loading label stays forever.

What to learn next

The ignore flag is the core of fetching without race conditions. Cleaning up timers and listeners covers the same return-cleanup shape for other work, and the dependency array decides when the async work should restart.

Rune AI

Rune AI

Key Insights

  • Never pass an async function directly to useEffect.
  • Declare an async function inside the Effect and call it.
  • Use a try/catch block inside the async function to capture errors.
  • Use an ignore flag in the cleanup to drop stale responses.
RunePowered by Rune AI

Frequently Asked Questions

Can I pass an async function directly to useEffect?

No. The setup must return either nothing or a cleanup function. An async function returns a Promise, so React cannot use it as a cleanup.

Where should the async logic go?

Declare the async function inside the Effect, call it, and do the cleanup work in the returned function.

How do I handle errors from async work?

Wrap the await calls in a try/catch block inside the async function, and store the error in state so the UI can show it.

Conclusion

useEffect needs a synchronous setup or a cleanup function, so async work goes inside a nested function that you call. Pair it with a cleanup that ignores stale results and you get the standard pattern.