Skip Main Navigation
Ben IlegboduBen Ilegbodu

Successfully using async functions in React useEffect

How to avoid the exhaustive deps ESLint error by properly using JavaScript async functions within the React useEffect Hook

Sunday, September 26, 2021 · 4 min read

JavaScript async functions making dealing with promises a bit simpler because it flattens out nested promises into sequential statements. But using async functions within React's useEffect() comes with a gotcha that I'd like to walk through and explain.

Let's say we have a useTopPlayers() custom Hook that retrieves the NBA best players in a specific statistical category. The NBA's "API" only returns the IDs of the players, so after getting the IDs we have to make another API request to get the raw player info for each. Finally we need to normalize the raw info into a useful format that our app can consume.

import { useEffect, useState } from 'react'
import Bugsnag from '@bugsnag/js'
import {
  getTopPlayers as getTopPlayersApi,
  getPlayersById as getPlayersByIdApi,
} from './api'
import { normalizeApiPlayers } from './utils'

const useTopPlayers = (category, season) => {
  const [players, setPlayers] = useState([])

  useEffect(() => {
    getTopPlayersApi(category, season)
      .then((playerIds) => {
        // avoid nesting by returning the promise returned by
        // `getPlayersByIdApi`
        return getPlayersByIdApi(playerIds)
      })
      .then((rawPlayers) => {
        setPlayers(normalizeApiPlayers(rawPlayers))
      })
      .catch((err) => {
        // notify our error monitoring (using Bugsnag)
        Bugsnag.notify(err)

        // `null` players means an error happened
        setPlayers(null)
      })
  }, [category, season])

  return players
}

This is pretty standard code. It's simplified a bit because I have abstracted the logic around making the two API requests and normalizing the data into helper functions. But there are two main drawbacks of using promises here:

  1. Any code depending on the result of the call to getTopPlayersApi() has to be nested within the .then() promise chain.
  2. Another async call within a .then() could result in nested promise chains which is basically the same as callback hell. We can at least flatten the chain by returning the promise returned by the nested async function in the outer .then().

Async functions to the rescue! The naive approach would be to add async to useEffect()'s callback function.

const useTopPlayers = (category, season) => {
  const [players, setPlayers] = useState([])

  // 🛑 DON'T DO THIS! 🛑
  useEffect(async () => {
    try {
      const playerIds = await getTopPlayersApi(category, season)
      const rawPlayers = await getPlayersByIdApi(playerIds)

      setPlayers(normalizeApiPlayers(rawPlayers))
    } catch (err) {
      Bugsnag.notify(err)
      setPlayers(null)
    }
  }, [category, season])

  return players
}

The code seems to flow more nicely using await, right? But we're unintentionally breaking one of the rules of the useEffect() Hook by making useEffect() asynchronous.

Despite breaking the rules, the majority of the time our code would still work fine. However, if we have multiple useEffect() calls that were order-dependent, we could run into a race-condition, creating a bug that would be super hard to track down. But if we're using the React Hooks ESLint Plugin (which we absolutely should be), it clues us in to our lurking issue.

Effect callbacks are synchronous to prevent race conditions. Put the async function inside:

useEffect(() => {
  async function fetchData() {
    // You can await here
    const response = await MyAPI.getData(someId);
    // ...
  }
  fetchData();
}, [someId]); // Or [] if effect doesn't need props or state

Learn more about data fetching with Hooks: https://reactjs.org/link/hooks-data-fetchingeslintreact-hooks/exhaustive-deps

Similarly, if we're using TypeScript it also warns us of the problem (although admittedly far less clearly).

Argument of type '() => Promise<void>' is not assignable to parameter of type 'EffectCallback'.
  Type 'Promise<void>' is not assignable to type 'void | Destructor'.
    Type 'Promise<void>' is not assignable to type 'Destructor'.
      Type 'Promise<void>' provides no match for the signature '(): void | { [UNDEFINED_VOID_ONLY]: never; }'.

What are these errors telling us? Well, useEffect() is supposed to either return nothing or a cleanup function. But by making the useEffect() function an async function, it automatically returns a Promise (even if that promise contains no data).

You may be tempted, instead, to move the async to the function containing the useEffect() (i.e. the custom Hook).

// 🛑 DON'T DO THIS! 🛑
const useTopPlayers = async (category, season) => {
  const [players, setPlayers] = useState([])

  useEffect(() => {
    try {
      const playerIds = await getTopPlayersApi(category, season)
      const rawPlayers = await getPlayersByIdApi(playerIds)

      setPlayers(normalizeApiPlayers(rawPlayers))
    } catch (err) {
      Bugsnag.notify(err)
      setPlayers(null)
    }
  }, [category, season])

  return players
}

But this doesn't work at all for 2 main reasons. First, by making the custom Hook async, we're now returning players data wrapped in a Promise instead of just players. Remember, async automatically makes the function return a Promise. Secondly, await only works if its direct containing function is async. You cannot put async on a top-level function and expect await to work within nested functions. So it not only doesn't work with React, but also isn't even valid JavaScript.

Instead, we can follow the lint error's suggestion by defining an async inner function within the useEffect() function and immediately calling it.

const useTopPlayers = (category, season) => {
  const [players, setPlayers] = useState([])

  useEffect(() => {
    // Add inner async function
    const fetchTopPlayers = async () => {
      try {
        const playerIds = await getTopPlayersApi(category, season)
        const rawPlayers = await getPlayersByIdApi(playerIds)

        setPlayers(normalizeApiPlayers(rawPlayers))
      } catch (err) {
        Bugsnag.notify(err)
        setPlayers(null)
      }
    }

    // Call function immediately
    fetchTopPlayers()
  }, [category, season])

  return players
}

Now the Hook is back to returning players data and the main useEffect() function is back to returning nothing. Instead, we've defined the fetchTopPlayers() inner function that we immediately call. We make fetchTopPlayers() an async function so that we can use await within it.

I gotta admit, having to define the inner function is a bit clunky. But, in my opinion, it's a small price to pay to drastically improve the developer experience of async useEffect calls. You know, what's actually the most annoying is having to come up with a non-duplicative name for the inner function. We could instead use an IIFE (immediately-invoked function expression).

const useTopPlayers = (category, season) => {
  const [players, setPlayers] = useState([])

  useEffect(() => {
    // use IIFE to avoid creating named function ðŸĪŠ
    ;(async () => {
      try {
        const playerIds = await getTopPlayersApi(category, season)
        const rawPlayers = await getPlayersByIdApi(playerIds)

        setPlayers(normalizeApiPlayers(rawPlayers))
      } catch (err) {
        Bugsnag.notify(err)
        setPlayers(null)
      }
    })()
  }, [category, season])

  return players
}

Prettier automatically adds that weird ; at the beginning of the statement. Because I don't normally use semicolons in my code, if there was a line prior to the IIFE the JavaScript interpreter wouldn't be able to properly understand what's going on.

But to me, this is taking a clunky solution and making it worse. ðŸĪŠ


So if you've added the async inner function before not fully understanding why, now you know. 😄 And if you've made the useEffect() function itself async, you have a sneaky bug waiting to bite you when you least expect it. 😂 You probably should go back and fix the code.

If you've got any questions or comments feel free to reach out to me on Twitter at @benmvp.

Keep learning my friends. ðŸĪ“

Subscribe to the Newsletter

Get notified about new blog posts, minishops & other goodies

​
​

Hi, I'm Ben Ilegbodu. 👋ðŸū

I'm a Christian, husband, and father of 3, with 15+ years of professional experience developing user interfaces for the Web. I'm a Google Developer Expert Frontend Architect at Stitch Fix, and frontend development teacher. I love helping developers level up their frontend skills.

Discuss on Twitter // Edit on GitHub