How to Use an Async Function in the React useEffect() Hook

In this article, we’ll look at different ways to easily call an async function inside the React useEffect() hook, along with pitfalls to avoid when working with async/await.

Call async Functions With then/catch in useEffect()

async functions perform an asynchronous operation in JavaScript. To wait for the Promise the async function returns to be settled (fulfilled or rejected) in the React useEffect() hook, we could use its then() and catch() methods:

In the following example, we call the fetchBooks() async method to fetch and display stored books in a sample reading app:

export default function App() {
  const [books, setBooks] = useState([]);

  useEffect(() => {
    // await async "fetchBooks()" function
    fetchBooks()
      .then((books) => {
        setBooks(books);
      })
      .catch(() => {
        console.log('Error occured when fetching books');
      });
  }, []);

  return (
    <div>
      {books.map((book) => (
        <div>
          <h2>{book.title}</h2>
        </div>
      ))}
    </div>
  );
}

async/await Problem: async Callbacks Can’t Be Passed to useEffect()

Perhaps you would prefer to use the async/await syntax in place of then/catch. You might try doing this by making the callback passed to useEffect() async.

This isn’t a good idea though, and if you’re using a linter it will inform you of this right away.

// ❌ Your linter: don't do this!
useEffect(async () => {
  try {
    const books = await fetchBooks();
    setBooks(books);
  } catch {
    console.log('Error occured when fetching books');
  }
}, []);

Your linter complains because the first argument of useEffect() is supposed to be a function that either returns nothing or returns a function to clean up side effects. But async functions always return a Promise (implicitly or explicitly), and Promise objects can’t be called as functions. This could cause real issues in your React app, such as memory leaks.

useEffect(async () => {
  const observer = () => {
    // do stuff
  };

  await fetchData();

  observable.subscribe(observer);

  // Memory leak!
  return () => {
    observable.unsubscribe(observer);
  };
}, []);

In this example, because the callback function is async, it doesn’t actually return the defined clean-up function, but rather a Promise object that is resolved with the clean-up function. Hence, this clean-up function is never called, and the observer is never unsubscribed from the observable, resulting in a memory leak.

So how can we fix this? How can we use the await operator with an async function in the useEffect() hook?

async/await Solution 1: Call async Function in IIFE

One straightforward wait to solve this problem is to await the async function in an immediately invoked function expression (IIFE):

const [books, setBooks] = useState([]);

useEffect(() => {
  (async () => {
    try {
      const books = await fetchBooks();
      setBooks(books);
    } catch (err) {
      console.log('Error occured when fetching books');
    }
  })();
}, []);

As the name suggests, an IIFE is a function that runs as soon as it is defined. They are used to avoid polluting the global namespace and in scenarios where trying an await call could cause problems in the scope containing the IIFE (e.g., in the useEffect() hook, or in the top-level scope for pre-ES13 JavaScript).

async/await Solution 2: Call async Function in Named Function

Alternatively, you can await the async function inside a named function:

useEffect(() => {

  // Named function "getBooks"
  async function getBooks() {
    try {
      const books = await fetchBooks();
      setBooks(books);
    } catch (err) {
      console.log('Error occured when fetching books');
    }
  }

  // Call named function
  getBooks();
}, []);

Remember the example using the observable pattern? Here’s how we can use a named async function to prevent the memory leak that occurred:

// ✅ Callback is not async
useEffect(() => {
  const observer = () => {
    // do stuff
  };

  // Named function "fetchDataAndSubscribe"
  async function fetchDataAndSubscribe() {
    await fetchData();
    observable.subscribe(observer);
  }

  fetchDataAndSubscribe();

  // ✅ No memory leak
  return () => {
    observable.unsubscribe(observer);
  };
}, []);

async/await Solution 3: Create Custom Hook

We can also create a custom hook that behaves similarly to useEffect() and can accept an async callback without causing any issues.

The custom hook could be defined this way:

export function useEffectAsync(effect, inputs) {
  useEffect(() => {
    return effect();
  }, inputs);
}

And we’ll be able to call it from multiple places in our code like this:

const [books, setBooks] = useState([]);

useEffectAsync(async () => {
  try {
    const books = await fetchBooks();
    setBooks(books);
  } catch (err) {
    console.log('Error occured when fetching books');
  }
});

With these three approaches, we can now easily use the await operator with async functions in the useEffect() hook.

Define Async Function Outside useEffect()

To define a named async function outside the useEffect() hook, you can wrap the function with the useCallback() hook:

const getBooks = useCallback(async () => {
  try {
    const books = await fetchBooks();
    setBooks(books);
  } catch (err) {
    console.log('Error occured when fetching books');
  }
}, []);

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

Without useCallback(), the getBooks() function will be re-created on every re-render, triggering useEffect() and causing performance problems.



Every Crazy Thing JavaScript Does

A captivating guide to the subtle caveats and lesser-known parts of JavaScript.

Every Crazy Thing JavaScript Does

Sign up and receive a free copy immediately.

Leave a Comment

Your email address will not be published. Required fields are marked *