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
}
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
}, [])
useEffect(() => {
// only do y when component has updated (but not on initial render)
}, [])
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
})
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
}, [])
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])
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} />
}
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
}
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} />
}
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} />
}
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} />
}
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
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} />
}
Why useState?
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
})
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)
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)
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:However, I could be wrong, since the function call
unsubscribePlayers
may indeed return a function.Anyway, nice post! Keep it up, very entertaining.
Hey Polar, good catch, thank you. I've updated it :)