React: Using useEffect Effectively

Whether you’re new to React or you are recently migrating from class components (this late?), you might not be familiar with how useEffect works.

Actually, you might be using it still from other existing (or copied 😉) code, but have no idea (or just a small idea) what’s actually happening.

In this post we’ll dive in to this very flexible hook, some example, and some real, common use-cases.

Table of contents

What is React.useEffect?

This is a React hook that lets you introduce side-effects into your components.

Here is some example code of a common useEffect hook:

const MyComponent = () => {
  const [posts, setPosts] = React.useState([])

  // This will fire only once when the component mounts
  React.useEffect(() => {
    PostsAPI.getPostsList().then((data) => setPosts(data))
  }, [])
  return <div>{JSON.stringify(posts)}</div>
}

What are side effects?

Well, side effects are a general programming term, under the larger term “effects”. Effects have a wide meaning, and side-effects are a subset of that. The short version of the definition of a side-effect is “a function (or piece of code) that introduces changes to any value that is not inside its scope” - whether input arguments, or outside scoped variables (or global variables).

In React components, that mostly means any function inside a component that introduces a change that is outside the scope of rendering itself.

Are you calling an outside API? You are introducing an effect. Saving a value to the state? Introduces an effect. As long as it’s outside the scope of rendering the component itself, it’s a side-effect.

Side-effects are not bad per-se - but introducing them needs to be done with intent, and with caution.

React side-effects example

React.useEffect is a hook that lets you wrap side-effects in a safe way so that you can avoid unnecessary renders, and use the necessary ones correctly. Consider the following code:

const MyComponent = () => {
  const [count, setCount] = React.useState(0)

  CounterAPI.getCount().then((data) => setCount(data))

  return <div>{count}</div>
}

If you haven’t noticed th the problem at first glance - don’t worry.

What happened here is we implicitly introduced a side effect right inside our rendering.

As soon as the component renders, the CounterAPI will get called, return something, and setCount will trigger a state change, which causes a render.

Then immediately, the new render runs, and another API call is made… This will go on forever, so React (or your browser) will crash in some way, killing the page.

This is why we need to guard against side-effects. Here is a simple change to fix this:

  const MyComponent = () => {
    const [count, setCount] = React.useState(0)
+   React.useEffect(() => {
      CounterAPI.getCount().then(data => setCount(data))
+   }, [])
    return <div>{count}</div>
  }

This will mean our API only calls once.

A replacement for lifecycle methods

If you have ever used class-based components in React, you might be familiar with the term. In these types of components, sometimes we would need to fire events when the component mounts for the first time, or when one of the props changes, to sync our state properly with our business-logic.

This is the same effect from before, but in the old* method:

class MyComponent extends React.Component {
  render() {
    // return ...
  }

  componentDidMount() {
    MyAPI.getResults().then((data) => this.setState({ apiData: data }))
  }
}

* It's not deprecated, and React will support it for a while to come. But class-based components are generally considered to be outdated, a bit less easy to predict sometimes - especially for newcomers, and generally too verbose for sometimes really simple code.

The useEffect hook is also the de-facto replacement to the lifecycle methods, which are unavailable when you are using function components.

Let’s dive into how it’s used.

Structure of the useEffect hook

A useEffect call takes 2 arguments.

React.useEffect(
  () => {
    // the side-effect code

    // Optionally return a clean-up function
    return () => doCleanup()
  },
  [
    /* dependencies */
  ],
)

The first is a function to run at… “some time”. The time in which to run this effect differs on your use case, and your usage of the 2nd argument. We will talk about how it works shortly.

The second is optional - but in my opinion, very required - the dependency array. Like many React hooks (see my post about useMemo and useCallback), it is a list of changes that the component listens to, only running the effect after changes to the dependencies were detected.

In the case of useEffect, the effect always runs at least once, even if that means all the values it depends on might be undefined. So wait, how does this work?

Thinking “Reactive”

This is a bit confusing. We are listening to changes now - okay. I will elaborate a bit.

If in the past we used to rely on the framework to call specific methods in different lifecycle times, now we are going to change our way of thinking a bit. This method introduced many problems, especially because developers were often introducing side-effects where they shouldn’t be possible, introducing bugs and other problems.

Many hooks now rely on dependency arrays to define when they are being re-built. Our way of thinking will be similar - our function will run when changes are made, and they’re always made at least once - in the mounting phase.

If we want to fire code once when our component is mounted, we can introduce an effect with 0 dependencies. This causes it to only run once, as no changes will ever be introduced to this very constant array. This causes the hook to behave exactly as componentDidMount did.

Dependencies: [] !== undefined

It’s important to note that supplying no array in the dependency argument, and supplying an empty array are 2 different things! Try it out yourself to see the difference.

const MyComponent = () => {
  const [count, setCount] = React.useState(0)

  React.useEffect(() => console.log('no array'))
  React.useEffect(() => console.log('empty array'), [])

  return <div onClick={() => setCount((prev) => ++prev)}>{count}</div>
}

Running a component with this code will show different results.

Immediately, we will see this in the log:

no array
empty array

Nothing special yet. Let’s click the div and see what happens.

no array

Ahh… What’s this? It ran again? But we supplied no dependencies?

Supplying no value at all means it will update every time the component renders, not just once. The dependency needs to be an array for any comparison to happen. If it’s undefined, comparison is skipped and the effect runs every time.

How can we use this now?

Let’s review the basics we learned about this hook.

  1. It runs at least once
  2. It then runs:
    1. every time its dependency array contents change, or
    2. if there is no array, after every render

Component mounting

The simplest effect we learned about is commonly referred to as a mount effect. It runs once since you supply no dependencies.

Let’s say we are viewing a list of posts.

const PostList = () => {
  const [posts, setPosts] = React.useState([])
  const [page, setPage] = React.useState(0)

  React.useEffect(() => {
    setLoading(true)
    PostsAPI.getPosts({ page: page }).then((data) => {
      setPosts(data.posts)
      setLoading(false)
    })
  }, [])

  return (
    <div>
      {loading
        ? 'Loading...'
        : posts.forEach((post, i) => {
            return <div key={post.id}>{post.title}</div>
          })}
    </div>
  )
}

This will fetch the posts once, and once only.

Then we have more cases, where we decide what to supply, and we can make our component react to changes in either the props or change, and introduce the effects we need.

Let’s consider an extension to this use-case to test with.

Reacting to a state change

We will add a page selector, which lets us change which page of the posts we are viewing. For the sake of simplicity, the count per page is implicit and hard-coded in the backend.

  const PostList = () => {
    const [posts, setPosts] = React.useState([])
    const [loading, setLoading] = React.useState(true)
+   const [page, setPage] = React.useState(0)

    React.useEffect(() => {
      setLoading(true)
      PostsAPI.getPosts({ page: page }).then((data) => {
        setPosts(data.posts)
        setLoading(false)
      })
-   }, [])
+   }, [page])

    return (
      <div>
+       <span onClick={() => setPage(prev => ++prev)}>Next page &raquo;</span>
        {loading
          ? 'Loading...'
          : posts.forEach((post, i) => {
              return <div key={post.id}>{post.title}</div>
            })}
      </div>
    )
  }

As you can see, we supplied our page as a dependency for the API call. This means that once the component mounts, and then every time this count changes afterwards, React will run our effect again, fetching the posts from the API. Neat!

The cleanup function, timeouts and intervals

Another great use-case is to run some logic on a fixed interval, or after a timeout. Let’s say we want to increase a counter every second.

const Counter = () => {
  const [count, setCount] = React.useState(0)

  React.useEffect(() => {
    setInterval(() => {
      setCount((prev) => ++prev)
    }, 1000)
  }, [])

  return <div>{count}</div>
}

This works great! Every second, we get a new count with a larger number.

But this introduces a bug: navigate to another route, or get this component removed in some way. You will notice the interval keeps running!

In a case like this, we want to make sure our interval or timeout stops when the component is no longer mounted, in order to prevent a memory leak.

This is where cleanup functions come in handy. A useEffect call can also accept a function as a result of its callback.

That function will run once one of the dependencies change, before the “new” effect runs. It will also run once the component unmounts, i.e., is being removed from the tree in some way. These cases are the same phase that calls the cleanup function, with the only difference being whether a new effect will replace the discarded one, or will the effect end its life there.

This allows us to do things like cancel timers, API requests, or make some other cleanup to make sure nothing happens once it’s no longer necessary. React class-component users will recognize this as being a similar use-case for using componentWillUnmount lifecycle method. Let’s see how we can fix our previous example:

  const Counter = () => {
    const [count, setCount] = React.useState(0)

    React.useEffect(() => {
-     setInterval(() => {
+     const id = setInterval(() => {
        setCount((prev) => ++prev)
      }, 1000)
+     return () => clearInterval(id)
    }, [])

    return <div>{count}</div>
  }

As you may or may not already know, setInterval and setTimeout return an id token that we can later use to cancel it. By supplying it as a return function to useEffect, we are telling it to destroy our timer every time it needs to. If it is supposed to create a new one due to dependency change, it will still create it and our code will still work.

External listeners

The last prominent use-case we will show here is using external listeners inside components.

This is similar to our previous use-case, where we have a function with a side-effect that needs to be cleaned up. In fact it’s pretty much exactly the same. Let’s say we want to display the window resolution in our component (or make some condition depend on a value from there). Let’s use our previous knowledge to make a component that works for this.

const WindowSize = () => {
  const [width, setWidth] = React.useState(0)
  const [height, setHeight] = React.useState(0)

  React.useEffect(() => {
    const listener = () => {
      setWidth(window.innerWidth)
      setHeight(window.innerHeight)
    }
    window.addEventListener('resize', listener)
    return () => window.removeEventListener('resize', listener)
  }, [])

  return (
    <div>
      Width: {width}px
      <br />
      Height: {height}px
    </div>
  )
}

Now we have an effect that listens to window changes, and makes sure to update the state with it. Another success! And as you can see, it’s not much different than the interval/timeout example.

Conclusion

I hope that now you might see a bit more clearly how this hook is useful, and how to actually put it to use properly.

Experiment with using useEffect in different cases, and experiment with thinking more reactively about changes - making dependencies change things as they need, instead of always chaining function calls whenever you change values just to replicate the same behavior.