Avatar

Ciao, I'm Julia.

Do You Really Need a useEffect?

#react

6 min read

The more I work with React, the more paranoid I get around the misuse of the useEffect hook. I first came to React before the era of hooks. Classes were a thing and I thought about component lifecycle through the concepts of componentDidMount, componentDidUpdate, componentWillUnmount and the other related methods. The way I was then introduced to hooks was by a direct translation of these class-based methods, for example:

  • For componentDidMount
useEffect(() => {
  // ...
}, [])
  • For componentDidUpdate
useEffect(() => {
  // ...
}, [add, dependencies, here])
  • For componentWillUnmount
useEffect(() => {
  // ...
  return () => {
    // cleanup function
  }
}, [dependencies])

I then went on my merry way for a while, not really stopping to understand what useEffect was actually designed to do, and whether sticking certain bits of code in it was really best practice. There's nothing like getting bogged down with an inconsistent bug to learn some valuable lessons the hard way though, so I've slowly been getting rid of my bad habits when it comes to using useEffect. 😅

Happily, I've noticed there's been a lot more information out there in recent times, spreading awareness of this very misunderstood React hook. I came across this page in React's beta docs recently, which I found exceptionally useful, and would encourage everyone working with React to read. This post is basically me rewriting a bunch of the same information in my own words, as an internalisation exercise (with the added bonus of coming up with my own cheat sheet). You can decide to stick with me here, or go to the original source of truth. 😉

Key points

  • useEffect should be used to synchronise your app with external systems. This is a point I made in Practical Tips When Using React’s Two Most Common Hooks (which you can read next if you're interested in some more practical tips of using useEffect).
    • A lot of times, this might mean fetching data from an external API. You can do this within useEffect, but think about whether the framework you're using (if any), gives you more efficient built-in options for fetching data.
    • If you do fetch data within useEffect, remember to add a cleanup function to avoid race conditions and memory leaks.
  • Another use case for useEffect is for any code that needs to run because a component was displayed.
    • Otherwise, think about sticking your code in events.
  • Don't transform your data for rendering within useEffect. This is inefficient as the steps React takes are as follows:
    • A component's state updates, which triggers...
    • ...the running of the component's functions to determine what should be rendered.
    • These changes are committed to the DOM...
    • ...which updates what's displayed on the screen.
    • useEffect is then run at this point.
      • If there's a state change in here, the whole process repeats.
      • You therefore want to ensure your data transformations happens at the top level of the component, so they be run before changes are committed to the DOM. If you're worried about expensive calculations, wrap these in useMemo.
  • Don't handle user events within useEffect. Based on the above steps, by the time we come to useEffect, we will have lost the context in which the user event happened.
    • User events should be handled in event handlers instead.
  • If you need to reset the state of an entire component tree, pass a different key to it.
  • setState changes due to changes in props should be done during rendering.
  • Think about batching the updating of several pieces of state within a single event.
  • Think about whether lifting state up make sense, if you find yourself trying to synchronise state across various components.

Updating state based on props or state

Use derived state and set this at the top level of the component (i.e. calculate it during rendering).

function Game() {
  const [name, setName] = useState("Hogwart's Legacy")
  const [rating, setRating] = useState(5)

  const gameReview = `${name} - ${rating} stars`
}

Use useMemo to cache expensive calculations if you're worried about performance.

function GamesList({ allGames }) {
  const [rating, setRating] = useState(5)

  const filteredGamesByRating = useMemo(() => {
    return getFilteredGamesByRating(allGames, rating)
  }, [allGames, rating])
}

Reset all state on prop(s) change

Don't do this:

export function GameProfilePage({ gameId }) {
  const [userReviews, setUserReviews] = useState('')

  // Don't do this!
  useEffect(() => {
    setUserReviews('')
  }, [gameId])

  // Do this instead
}

Use the key prop instead:

export function GameProfilePage({ gameId }) {
  return <GameProfile gameId={gameId} key={gameId} />
}

function GameProfile({ gameId }) {
  const [userReviews, setUserReviews] = useState('')
}

Note that only GameProfilePage is exported. Other files do not need to be aware of how it is implemented with GameProfile.

Use event handlers

Stick your logic in an event handler if you need it to run because of a specific interaction. If however, your logic needs to run because of what a user sees on screen, it can go into a useEffect.

function GameReviewForm() {
  // Only want this to run once, upon a user seeing the page on screen
  useEffect(() => {
    post('/analytics/track', { page: 'game_review_form' })
  }, [])

  // Call this on button click
  function handleSubmit(e) {
    e.preventDefault()
    post('/api/createGameReview', { game, review })
  }
}

Ensuring logic only runs once on mount

Remember that useEffects run twice in development, even if its dependency array is empty. To work around this:

let initialised = false

function App() {
  useEffect(() => {
    if (!initialised) {
      initialised = true
      loadData()
    }
  }, [])

  // ...
}

Alternatively, you can check if we're running in the browser, before the app renders.

if (typeof window !== 'undefined') {
  loadData()
}

function App() {
  //...
}

Subscribing to an external store

I can't say I've ever had to use this, but it's good to know that React has a purpose-built hook called useSyncExternalStore for subscribing to external stores (e.g. if data is from a browser API). What we're trying to avoid is manually syncing mutable data to React state via useEffect.

Fetching data in useEffect

If you need to fetch data and it doesn't belong in an event handler, stick it in a useEffect but remember to add a cleanup function.

function SearchGame({ query }) {
  const [games, setGames] = useState([])

  useEffect(() => {
    let staleResults = false

    getResults(query).then((jsonResults) => {
      if (!staleResults) {
        setGames(jsonResults)
      }
    })

    return () => {
      staleResults = true
    }
  }, [query])
}

If you're using a framework, think about whether there are more optimal ways of fetching data. These options may offer caching, race condition handling, error states etc. out of the box.

© 2016-2024 Julia Tan · Powered by Next JS.