This post was sent to my newsletter last week.
Thanks to Dan Abramov for reviewing it and suggesting improvements.
If you're a fan of React, you might have already heard that the release with Hooks (v16.8) is here.
I've been playing with the alpha version for a few weeks now and I really like it. The adoption hasn't been all rainbows and unicorns though.
Learning useState
and useReducer
was pretty straightforward and has improved how I handle state.
I wrote about useState
in an earlier post. Here's the short version:
function Counter() {
/*
create a new state pair with useState,
you can specify the initial value
as an argument
*/
const [count, setCount] = useState(0)
/*
create a function to increase this count
you have access to the current count as it
is a local variable.
Calling setCount will trigger a re-render
just like setState would.
*/
function increase() {
setCount(count + 1)
}
return (
<div>
{count}
<button onClick={increase}>Increase</button>
</div>
)
}
However, I really struggled with the useEffect
hook.
The Effect Hook lets you perform side effects in function components.
Side effects can mean anything from updating the document title to making an API request. Anything that happens outside your React render tree is a side effect for the component.
With classes, you would typically do this in componentDidMount
. With hooks, it looks like this:
import React, { useState, useEffect } from 'react'
// username is passed in props
render(<UserProfile username="siddharthkp" />)
function UserProfile(props) {
// create a new state pair with empty object as default
const [user, setUser] = useState({})
// create a pair for loading state
const [loading, setLoading] = useState(false)
// Similar to componentDidMount
useEffect(function() {
// set loading to true at start
setLoading(true)
// fetch the user's details
// username is passed in props
fetch('/get-user?username=' + props.username)
.then(response => response.json())
.then(user => {
setUser(user) // set user in state
setLoading(false) // set loading to false
})
})
if (loading) return <div>Fetching user... </div>
else return <div>Hi {user.name}</div>
}
This feels familiar. It looks like componentDidMount
in a different suit.
Well, it doesn't have the same way. The above code has a bug!
Look at this preview, it's on an infinite loop of fetching user and re-rending it (and not just because it's a gif!)
componentDidMount
is called after the component has mounted. It fires just once.
On the other hand, the effect inside useEffect
is applied on every render by default.
This is a subtle shift in the mental model, we need to change how we think about the component lifecycle - instead of mount and update, we need to think in terms of renders and effects
useEffect
lets us pass an optional argument - an array of dependencies
that informs React when should the effect be re-applied. If none of the dependencies change, the effect will not be re-applied.
useEffect(function effect() {}, [dependencies])
Some folks find this annoying - it feels like something that was simple is now complex with no benefit.
The benefit of useEffect
is that it replaces three different API methods (componentDidMount
, componentDidUpdate
and componentWillUnmount
) and hence makes you think about all those scenarios from the start - first render, update or re-render and unmount.
In the above component, the component should fetch user details again when we want to show a different user's profile, i.e. when props.username
changes.
With a class component, you would handle this with componentDidUpdate
or getDerivedStateFromProps
. This usually comes as an after thought and until then the component shows stale data.
With useEffect
, you are forced to think about these use cases early on. We can pass props.username
as the additional argument to useEffect
.
useEffect(
function() {
setLoading(true) // set loading to true
// fetch the user's details
fetch('/get-user?username=' + props.username)
.then(response => response.json())
.then(user => {
setUser(user) // set user in state
setLoading(false) // set loading to false
})
},
[props.username]
)
React will now keep track of props.username
and re-apply the effect when it changes.
Let's talk about another kind of side effect: Event listeners.
I was trying to build a utility that shows you which keyboard button is pressed. Adding a listener on window
to listen to keyboard events is a side effect.
Step 1: Add event listener in effect
function KeyDebugger(props) {
const [key, setKey] = useState(null)
function handleKeyDown(event) {
setKey(event.key) // set key in state
}
useEffect(function() {
// attach event listener
window.addEventListener('keydown', handleKeyDown)
})
return <div>Last key hit was: {key}</div>
}
This looks similar to the previous example.
This effect will be applied on every render and we will end up with multiple event listeners that fire on the same event. This can lead to unexpected behavior and eventually a memory leak!
Step 2: Clean up phase
useEffect
gives us a way of cleaning up our listeners.
If we return a function from the effect, React will run it before re-applying the effect.
function KeyDebugger(props) {
const [key, setKey] = useState(null)
function handleKeyDown(event) {
setKey(event.key)
}
useEffect(function() {
window.addEventListener('keydown', handleKeyDown)
return function cleanup() {
// remove the event listener we had attached
window.removeEventListener('keydown', handleKeyDown)
}
})
return <div>Last key hit was: {key}</div>
}
Note: In addition to running before re-applying an effect, the cleanup function is also called when the component unmounts.
Much better. We can make one more optimisation.
Step 3: Add dependencies for re-applying effect
Remember: If we don't pass dependencies, it will run on every render.
In this case, we only need to apply the effect once, i.e. attach event listener on window once.
Unless the listener itself changes, of course! We should add the listener handleKeyDown
as the only dependency here.
function KeyDebugger(props) {
const [key, setKey] = useState(null)
function handleKeyDown(event) {
setKey(event.key)
}
useEffect(
function() {
window.addEventListener('keydown', handleKeyDown)
return function cleanup() {
window.removeEventListener('keydown', handleKeyDown)
}
},
[handleKeyDown]
)
return <div>Last key hit was: {key}</div>
}
The dependencies
are a powerful hint.
- no dependencies: apply the effect on every render
-
[]
: only apply on first render -
[props.username]
: apply when the variable changes
We can even abstract this effect out into a custom hook with cleanup baked in. This makes our component worry about one less thing.
function KeyDebugger(props) {
const [key, setKey] = useState(null)
function handleKeyDown(event) {
setKey(event.key)
}
useEventListener('keydown', handleKeyDown)
return <div>Last key hit was: {key}</div>
}
// re-usable event listener hook with cleanup
function useEventListener(eventName, callback) {
useEffect(function() {
window.addEventListener(eventName, callback)
return function cleanup() {
window.removeEventListener(eventName, callback)
}
}, [])
}
Note: useEventListener
as defined above works for our example, but is not the complete implementation. If you're curious what a robust version would look like, see this repo.
Let's add one more feature to our KeyDebugger
. After a second, the key should disappear until another key is pressed.
That's just a setTimeout
, should be easy right?
In handleKeyDown
, we can unset the key after a delay of a second. And as responsible developers, we will also clear the timeout in the cleanup function.
function KeyDebugger(props) {
const [key, setKey] = useState(null)
let timeout
function handleKeyDown(event) {
setKey(event.key)
timeout = setTimeout(function() {
setKey(null) // reset key
}, 1000)
}
useEffect(function() {
window.addEventListener('keydown', handleKeyDown)
return function cleanup() {
window.removeEventListener('keydown', handleKeyDown)
clearTimeout(timeout) // additional cleanup task
}
}, [])
return <div>Last key hit was: {key}</div>
}
This code has become a little more complex than before, thanks to the two side effects happening in the same effect - setTimeout
nested within a keydown
listener. This makes the changes harder to keep track of.
Because the two effects are nested, we couldn't reap the benefits of our custom hook as well. One way to simplify this code is to separate them into their own respective hooks.
Sidenote: There is a very subtle bug in the above code which is difficult to surface - Because timeout is not cleared when key
changes, old callbacks will continue to be called which can lead to bugs.
function KeyDebugger(props) {
const [key, setKey] = useState(null)
function handleKeyDown(event) {
setKey(event.key)
}
// keyboard event effect
useEventListener('keydown', handleKeyDown)
// timeout effect
useEffect(
function() {
let timeout = setTimeout(function() {
setKey(null)
}, 1000)
return function cleanup() {
clearTimeout(timeout)
}
},
[key]
)
return <div>Last key hit was: {key}</div>
}
By creating two different effects, we are able to keep the logic separate (easier to track) and define different dependencies for each effect. If we want, we can extract the timeout effect into a custom hook as well - useTimeout.
Sidenote: Because this component runs cleanup on every key
change, it does not have the sidenote bug from before.
I know it sounds difficult at first, but I promise it will become easy with a little practice.
Hope that was useful in your journey.
Sid
P.S. I'm working on a React Hooks course - Learn React Hooks by building a game. I really believe it is going to be amazing.
Visit react.games to watch a preview of the course and drop your email to get a discount when it launches (March 15).
Top comments (0)