DEV Community

Christine Schorn (she/her)
Christine Schorn (she/her)

Posted on • Updated on

React's useEffect & useState hooks

Disclaimer: This is not a basic introduction to hooks. There are many great tutorials out there that cover that part, like the React docs themselves.


As part of the Junior Engineering Program at 99designs, I did a little deep dive into the useEffect and useState hooks of React.

It was quite interesting for me to learn on a topic that I was relatively familiar with (I've used both those hooks heaps of times) and to see how much there still was that I didn't quite properly understand yet.

My starting point was the why of things. I looked through the original motivation behind introducing hooks, useEffect and useState in general. You can find this doc here.

Why useEffect?

The main motivation behind introducing the useEffect hook was to make complex components easier to manage and read.

Hooks let you split one component into smaller functions based on what pieces are related.

Before the useEffect hook and function components were introduced, the standard way of managing side effects inside class components were lifecycle methods.

However, they presented you with a particular problem where you had to split your code logic based on when something was happening, not what was happening. As a result, your code was hard to read and difficult to test as well.

Here you can see a very conceptual example of this problem:

 componentDidMount() {
    // do x immediately after component has mounted 
    // also do y immediately after component mounted
 }
 componentDidUpdate() {
    // only do y when component has updated (but not on initial render)
 }
 componentWillUnmount() {
    // cleanup x immediately before component has unmounted
 }
Enter fullscreen mode Exit fullscreen mode

You can see that our code is all over the place. componentDidMount contains logic related to functionality x AND y, while componentDidUpdate just contains logic related to functionality y, and componentWillUnmount on the other hand again contains logic only related to x. This makes code hard to read and test as I mentioned earlier.

So in comes our useEffect hook which helps us solve this issue with a much cleaner approach that allows us to split our logic based on the what of things, not the when.

By default, useEffect runs after the first render and after every update as well, so basically after every render, to put it simpler.

Let's return to our conceptual example from before and see how useEffect is solving our previously described problem.

useEffect(() => {
   // do x immediately after component has mounted
   // cleanup x immediately before component has unmounted
}, [])
Enter fullscreen mode Exit fullscreen mode
useEffect(() => {
   // only do y when component has updated (but not on initial render)
}, [])
Enter fullscreen mode Exit fullscreen mode

You can see how we are now able to group based on the different things that are happening and x and y are no longer mingled and mixed up.

The result: easier to read and much easier to test as well.

At this point, it is also worth noting that React strongly encourages you to use several effects in your component if you have a lot of different things happening. So don't worry if you end up with 3 different useEffect inside your component, that's actually considered good practice.

The dependency array of useEffect

So we've seen the first argument that our useEffect hook takes, a function where you'll outline all the magical things you want to happen. But the useEffect hook also takes in a second argument, often called dependency array, which is extremely important, and for me, this deep dive really helped me better understand how this second argument works, why it's so important, and what are some gotchas.

React introduced the dependency array to improve performance. The way it works is relatively straightforward if you're working with primitive values such as booleans, numbers, or strings. There are three scenarios that you can create:

1. Not passing the dependency array - not really recommended

If you don't pass a second argument (even if it's empty) your effect will re-run on every re-render, which isn't great for performance

useEffect(() => {
    // no dependency array - runs on every re-render
})
Enter fullscreen mode Exit fullscreen mode

2. Passing an empty dependency array

If you just pass an empty array as a second argument, you're basically telling React that your effect has NO dependencies and it'll never re-run

useEffect(() => {
    // empty dependency array - effect has NO dependencies and never re-runs
}, [])
Enter fullscreen mode Exit fullscreen mode

3. Passing values to your dependency array - probably the most used use-case

The rule of thumb is that if you are using any props or state variables in your effect, you should pass them again to your dependency array.
This way React can keep track of when one of these values has updated and consequently will re-run your effect on re-render.

useEffect(() => {
    // dependency array with values - if one of the values has changed, 
    // effect will re-run
}, [value1, value2])
Enter fullscreen mode Exit fullscreen mode

As I mentioned earlier, this works pretty well when you're dealing with primitive values. With more complex values like objects, arrays, and functions, however, you need to pay a bit more attention to detail and might come across some use cases that need a bit of extra work.

The reason why complex values don't work the same way as primitive values lies in the way React, or rather JavaScript handles those values. Under the hood, React uses the Object.is method.

So what does that mean exactly?

When you have an object, array, or function in your component (whether that's a state variable or props) React stores a reference to that object in memory (like an address where that object lives in memory).

The problem is that you don't have any guarantees that on the next re-render the reference to your object will be the same, in fact, it's pretty likely that it won't be.

As a consequence, when React compares the value you have passed to the dependency array in your useEffect, to the original one, they won't be the same because their "address" in memory has changed on the re-render and thus, even if your value hasn't been updated, your effect will re-run again and again because the two values reference a different object in memory (even though to you they look the same).

Let's look at an example:

const Team = ({ team }) => {
  const [players, setPlayers] = useState([])

  useEffect(() => {
    if (team.active) {
      getPlayers(team.id).then(setPlayers)
    }
  }, [team])

  return <Players team={team} players={players} />
}
Enter fullscreen mode Exit fullscreen mode

So let's say you have an object that you pass to your component as props. Here we have a Team component that takes in a team object that looks like this:

const team = {
    id: 1,
    name: 'Bulldogs',
    active: true
}
Enter fullscreen mode Exit fullscreen mode

On every re-render, the reference to your team object will most likely be different.

So when you pass it to your dependency array and React checks whether this object has changed or not and whether to run the effect again or not, the comparison will return false causing your effect to re-run on every re-render.

So what can you do to avoid this? There are several possible approaches and I'm just listing a few of them.

1. Only pass what you really need and use in your useEffect hook:

Let's have a look at our Team component again:

const Team = ({ team }) => {
  const [players, setPlayers] = useState([])

  useEffect(() => {
    if (team.active) {
      getPlayers(team.id).then(setPlayers)
    }
  }, [team.id, team.active])

  return <Players team={team} players={players} />
}
Enter fullscreen mode Exit fullscreen mode

Inside our effect, we're really just using properties from our team object, namely team.active and team.id which are primitive values again.

As a result, we can just pass those exact values to our dependency array and thus avoid all the references/address comparison complications mentioned above. Now our effect will only re-run if team.id or team.active have changed.

2. Recreate the object to use inside of our effect:

Let's have a look at another example and assume that for some reason we need the whole team object in our useEffect and also in our component.

const Team = ({ id, name, active }) => {
  const [players, setPlayers] = useState([])

  useEffect(() => {
    const team = { id, name, active }

    if (team.active) {
      getPlayers(team).then(setPlayers)
    }
  }, [id, name, active])

  const team = { id, name, active }

  return <Players team={team} players={players} />
}
Enter fullscreen mode Exit fullscreen mode

We can just recreate the object twice, once inside of our useEffect hook and once in our component. It's not very expensive to do that, so you don't have to worry about performance issues when using this approach. It's actually not a bad practice to move everything you need into your effect where possible since this way you clearly know what you're using and depending on.

3. Memoisation - last resort:

As a very last resort, if you have some very expensive calculations that you want to avoid re-running on every re-render, you can use React's useMemo hook.

const Team = ({ id, name, active }) => {
  const team = useMemo(() => createTeam({ id, name, active }), [
    id,
    name,
    active,
  ])
  const [players, setPlayers] = useState([])

  useEffect(() => {
    if (team.active) {
      getPlayers(team).then(setPlayers)
    }
  }, [team])

  return <Players team={team} players={players} />
}
Enter fullscreen mode Exit fullscreen mode

Be aware though that using this hook itself is quite expensive, so you should think twice before using it. You can learn more about the useMemo hook here.

Cleaning your effect up

giphy (11)

Especially when you run timers, events, or subscriptions inside your effect, it can be useful to clean those up before the next effect and when the component unmounts to avoid memory leaks.

The way to go about this is to return a function from your effect that will act as a cleanup.

const Team = ({ team }) => {
  const [players, setPlayers] = useState([])

  useEffect(() => {
    if (team.active) {
      getPlayers(team.id).then(setPlayers)
    }
    subscribePlayers(players)

    return  (() => unsubscribePlayers(players)) // 'cleans up' our subscription
  }, [team.active, team.id])

  return <Players team={team} players={players} />
}
Enter fullscreen mode Exit fullscreen mode

Why useState?

giphy (12)

In a very simple way, useState lets you add React state to function components (like setState for class components).

A little tip when using this hook: split state into multiple state variables based on which values tend to change together (especially helpful when dealing with objects or arrays) and use it for simple state management.

If things get more complex in the way you manage state, there are other tools for that.

While I didn't find useState as complex as useEffect, there are some important characteristics to keep in mind when working with it:

1. Updating a state variable with the useState hook always replaces that variable instead of merging it (like setState does).
This is quite important when you're dealing with objects or arrays, for example.

If you're just updating one item in an array or one property value of an object, you will always have to spread in the original object or array to not overwrite it with just the part that you're updating.

const [team, setTeam] = useState(team)

setTeam({
    ...team,
    team.active: false
})
Enter fullscreen mode Exit fullscreen mode

2. It's asynchronous.
Quite important to keep in mind that when you call your function that sets state (setTeam, for example) it behaves asynchronously, so it just adds your value update request to a queue and you might not see the result immediately.

That's where the useEffect hook comes in very handy and lets you access your updated state variable immediately.

3. You can update state with a callback.
The useState hook gives you access to a so-called functional update form that allows you to access your previous state and use it to update your new state.

This is handy when your new state is calculated using the previous state, so for example:

const [count, setCount] = useState(0)

setState(prevState => prevState + 1)
Enter fullscreen mode Exit fullscreen mode

4. Only call useState at the top level.
You cannot call it in loops, conditions, nested functions, etc. When you have multiple useState calls, the order in which they are invoked needs to be the same between renderings.

There's so much more to hooks than what I've written down here, but those were the things that I think will help me most moving forward.

I've really enjoyed diving deeper into this topic and realised again just how powerful hooks are. I also feel way more confident using them now and hope that after reading this article you do too.

Top comments (2)

Collapse
 
polar profile image
Polar Humenn • Edited

Hi Christine. I believe you have an anomaly in your clean up example. The type of the return of your unsubscribePlayers(players) call is not specified, so the return may be misleading, as a call to unsubscribe the players immediately before returning. For the clean up to work properly, you must return a function that will be called when needed. Perhaps you meant the following:

return  (() => unsubscribePlayers(players));
Enter fullscreen mode Exit fullscreen mode

However, I could be wrong, since the function call unsubscribePlayers may indeed return a function.

Anyway, nice post! Keep it up, very entertaining.

Collapse
 
enitschorn profile image
Christine Schorn (she/her)

Hey Polar, good catch, thank you. I've updated it :)