Refactoring a component to use React hooks
React 16.8 introduced hooks; a new way to work with effects and state in React. No longer do React components that have state need to be ES2015 classes that extend React.Component
- hooks let us write components as functions and still have all the functionality of class based components.
It's important to note that React will continue to support class based components for a long time yet. It's advised that you consider hooks going forward, but there's no need to instigate a big migration of your code.
I wanted to get familiar with hooks and try them on some real life code, and this blog post is the result of doing that and writing down how I find it, and comparing the before and after code. This is far from a deep dive into hooks, but more a quick look at my first experience refactoring to use them. I hope you find it useful!
Although I've simplified the code for this example, I did really do this at work first on a real component that we shipped!
The component we are working with.
The component we're going to refactor takes an id
as a prop, and makes a request to an API to fetch data for the user with that given ID. Its id
prop can change at any time, so we also have to fetch the user data again if the ID changes. Therefore we have componentDidMount
and componentDidUpdate
to deal with the first render and any subsequent prop changes. The render
for this example just dumps the user data out, but in real life this would render a meaningful UI.
import React, { Component } from 'react'
export default class Demo extends Component {
constructor(props) {
super(props)
this.state = {
user: undefined,
}
}
componentDidMount() {
fetchUser(this.props.id).then(user => this.setState({ user }))
}
componentDidUpdate(prevProps) {
if (this.props.id !== prevProps.id) {
fetchUser(this.props.id).then(user => this.setState({ user }))
}
}
render() {
return (
<pre>
<code>{JSON.stringify(this.state.user, null, 4)}</code>
</pre>
)
}
}
Don't worry about the definition of
fetchUser
- it's a small wrapper aroundfetch
that talks to our API.
Refactoring to hooks
Let's start thinking about how we will refactor this to use hooks. There are two hooks we're going to use:
useState
, which lets us hold a piece of state in our component. We'll use this to hold theuser
data that we fetch from our API.useEffect
. This lets us run side effects in our components. That is, things that happen as a result of a React component being rendered. You can map this roughly onto the old React lifecycle methods - in fact the documentation says just that:If you’re familiar with React class lifecycle methods, you can think of useEffect Hook as componentDidMount, componentDidUpdate, and componentWillUnmount combined.
Because we're using hooks, we will also rewrite our component as a function. So we can start with our shell:
import React, { useState, useEffect } from 'react'
const DemoWithHooks = props => {
const [user, setUser] = useState(undefined)
useEffect(() => {
// TODO
})
return (
<pre>
<code>{JSON.stringify(user, null, 4)}</code>
</pre>
)
}
When we call useState
we get back an array with two items in. The first is the actual value of the state, and the second is a function used to update that value. You can call these whatever you'd like, although the user
and setUser
style is becoming convention. We're using ES2015 destructuring to keep the boilerplate down, but you could write it as:
const userState = useState(undefined)
const user = userState[0]
const setUser = userState[1]
The value passed to useState
is the original value. This is needed for the first render. Here I've explicitly passed in undefined
so it's clear that when this component runs we don't have a user yet. To get a user, we need to move on to the useEffect
hook.
useEffect
useEffect
takes a function and runs it when the component renders. This means it will run both when the component first mounts, and when the component is re-rendered. Don't worry though, we are able to have control over exactly when it is executed, and we'll see that shortly.
Let's fill our useEffect
call in with a function that fetches our user and updates the state. Note that we call setUser
from within useEffect
. This is common if you've got some state that you're setting by making an HTTP request.
useEffect(() => {
fetchUser(props.id).then(setUser)
})
When used in this manner, the function given to useEffect
will be called:
- when the component first renders
- anytime the component is subsequently rendered
As it happens, for our component this is OK, because we only have one prop that could cause an update - id
. And every time that property changes, we do want to fetch the user's data again.
But, what if this component took many props, or had other bits of state? In that case, whenever any of those props changed, and the component was rendered again, our fetchUser
code would run. It would do this even if props.id
hadn't changed, and that's just a wasted network request if we already have the data for that user.
In a class based component we would tackle this by adding a conditional to our componentDidUpdate
code:
componentDidUpdate(prevProps) {
if (this.props.id !== prevProps.id) {
fetchUser(this.props.id).then(user => this.setState({ user }))
}
}
This ensures we only make the network request when the data we care about has changed. We can do the same with useEffect
by passing a second argument which is an array of data that has to change for the effect to rerun:
useEffect(
() => {
fetchUser(props.id).then(setUser)
},
[props.id]
)
Now our effect will run on first render, and also whenever props.id
changes. If any other data changes, it won't trigger the effect.
The final component
const DemoWithHooks = props => {
const [user, setUser] = useState(undefined)
useEffect(
() => {
fetchUser(props.id).then(setUser)
},
[props.id]
)
return (
<pre>
<code>{JSON.stringify(user, null, 4)}</code>
</pre>
)
}
If you compare the code above to the starting component at the top of the post, I think it's much cleaner. The first component has some near-duplicated code in the componentDidMount
and componentDidUpdate
, which is entirely removed as useEffect
lets us express everything in one function. We also avoid the awkward comparison of props in componentDidUpdate
; something that's easy to get subtly wrong, especially in complex components, and cause bugs or pointless network requests. useEffect
lets us define the effect and what should cause it to rerun really concisely.
If you're using hooks, I also recommend the eslint-plugin-react-hooks package, which will give you handy linter errors or warnings for some common mistakes when using hooks. I've been finding it particularly useful for catching things I get slightly wrong as I adjust to using hooks over class based components.
If you're not sure where to start with hooks in your codebase, I'd really recommend this approach of picking one straightforward component and refactoring it. It's low risk, and a component with just one or two pieces of local state shouldn't take long to refactor. It's a great learning exercise and a good way of sharing knowledge of hooks across your team.