Read This Before Refactoring Your Big React Class Components to Hooks

Matt Kaufman
7 min readNov 4, 2019

--

Words of wisdom from the Ombud Front End team

We as engineers love to solve problems — that’s why, when the product organization doesn’t give engineers enough hard problems to solve, we’ll create problems ourselves!

Here at Ombud, we pride ourselves on putting in deliberate effort to keep up with the latest and greatest versions of the technologies we use. That’s why, when React Hooks was released out of beta in early 2018, we hopped right on the Hooks train without missing a beat. No more this ?! No more componentDidMount or componentDidUpdate?! A brand new way to write React components was an exciting prospect.

These new shiny tools were too enticing to resist.

Hooks madness ensued.

It’s like we were all children again, playing in the wildflowers and discovering just how big and wonderful the world of Front End Development can be.

But like any good childhood fairy tale, there was a dark monster of hidden complexities tucked inside the cave just a few miles down the yellow brick road. Sure enough, we got a little too excited.

I’m here to share our story with you and caution you from making our mistakes — to heed the advice that the React core team gave when hooks were released — “Don’t go refactor all your components to hooks”.

Of course, we didn’t listen.

Refactoring is fun!

At first, it seemed so easy. Converting a class component to hooks was *simple* and *straightforward*, right?

Not quite.

Consider this Class Component. A *very* simple MP3 Player UI.

class MusicPlayerUI extends React.Component {  constructor(props) {
super(props)

this.state = {
songs: props.allSongs,
currentSongId: '123id',
currentSong: findNewSong('123id')
}
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.allSongs !== this.props.allSongs) {
this.setState({
songs: this.props.allSongs,
})
}

if (prevState.currentSongId !== this.state.currentSongId) {
this.setState({
currentSong: findNewSong(this.state.currentSongId)
})
}
}
findNewSong = (songId) => {
return this.state.songs.find(song => {
return song.id === songId
})
}
setNewSong = (songId) => {
this.setState({
currentSongId: songId
}}
}
render() {
return (
<div>
<h1>
Current Song:
{this.state.currentSong.name}
by
{this.state.currentSong.artist}
</h1>
<MP3Player
mp3={this.state.currentSong.mp3}
setSong={
songId => this.setNewSong(songId)
}
/>
</div>
)
}
}

When we started the refactoring, it seemed like a very straight forward process to create a hooks based function component instead of a class component. The steps looked like this:

Step 1: Replace each key of this.state with a useState hook.

Ex:

Class

this.state = {
songs: props.allSongs,
currentSongId: '123id',
currentSong: findNewSong('123id')
}

Hooks

[songs, setSongs] = useState([])
[currentSongId, setCurrentSongId] = useState('123id)
[currentSong, setCurrentSong] = useState(findNewSong('123id'))

Easy enough, right? It feels good, it’s straightforward and best of all, you can get rid of that pesky this!

Click here to learn more about the state hook.

Step 2: Replace all this.setState with its own state updater.

Ex:

Class

this.setState({
songs: props.allSongs // [array of songs]
currentSongId: 2,
currentSong: findNewSong('123id')
})

Hooks

setSongs(newSongs) // [array of songs]
setCurrentSongId('2id')
setCurrentSong(findNewSong('2id'))

This one is even simpler than the first, but we start to see that this might not be the exact same code. This performs two distinct operations instead of one. React *does* batch some of these updates, but not in all cases. We’ll revisit this later.

Step 3: Remove this from all variables and internally declared functions.

Ex: Imagine we have a function that helps us find the current song

Class

this.findTheNewSong(songId) {
return this.state.songs.find(song => {
return song.id === songId
})
}

Hooks

function findTheNewSong(songId) {
return songs.find(song => {
return song.id === songId
}
}

Now our findTheNewSong function is a closure within our new function component! An easy refactor, but depending on the structure of your function and the work it’s supposed to do, it’s important to think about potentially stale data in your function.

Step 4: Replace all lifecycle components

Ex: This is where things start to get tricky!

Class

componentDidUpdate(prevProps, prevState) {
if (prevProps.allSongs !== this.props.allSongs) {
this.setState({
songs: this.props.allSongs,
})
}

if (prevState.currentSongId !== this.state.currentSongId) {
this.setState({
currentSong: findNewSong(this.state.currentSongId)
})
}
}

Hooks

useEffect(() => {
setCurrentSong(findNewSong(currentSongId))
}, [currentSongId])
useEffect(() => {
setSongs(props.allSongs)
}, [props.allSongs])

mmmm, so clean! So much less code and very similar, if not exact, behavior. useEffect will behave very similarly to componentDidUpdate. After *every render*, useEffect will execute. The variables that are inside the []'s are called dependencies and the array itself is called the dependency array. The internals of React will check to see if one of these dependencies changed, and if it did it’ll execute function passed as the first argument. One thing to keep in mind here is that *order matters*. It’s important to keep track of *what is being updated* in each hook, because if a latter useEffect is dependent on a value from an earlier effect, stale values can occur.

Click here to learn more about the useEffect hook

Step 5: Putting it all together

Here’s what the final Hooks component looks like:

function MusicPlayerUI(props) {
const [songs, setSongs] = useState(props.allSongs)
const [currentSongId, setCurrentSongId] = useState('123id')
const [currentSong, setCurrentSong] =
useState(findNewSong('123id'))

useEffect(() => {
setCurrentSong(findNewSong(currentSongId))
}, [currentSongId])
useEffect(() => {
setSongs(props.allSongs)
}, [props.allSongs])
function findNewSong(songId) {
songs.find(song => {
return song.id === songId
})
}
return (
<div>
<h1>
Current Song:
{currentSong.name} by {currentSong.artist}
</h1>
<MP3Player
mp3={currentSong.mp3}
setSong={songId => setCurrentSongId(songId)}
/>
</div>
)
}

Pretty slick, right? I’m inclined to agree. This refactor *feels* good because theres less code, its easier to read and overall more expressive — as long as we understand how useEffect works, of course….

But what’s the catch?

It can’t be that easy — and of course it isn’t. Once we tried to refactor our bigger, more complex components, we ran into a lot of “gotchas”. We followed the steps above, but inevitably we would run into a complexity along the way. Sometimes we had a lot more complex state to manage, or maybe we had multiple, asynchronous state updates that needed to be made. Perhaps we had variables that we dependent on one another, and that caused complications too!

Let’s work through some of those complications we experienced.

Complication: Complex state

When we were refactoring our class Components, some of them were just massive. They were container components that maintained a lot of UI state, even some state objects with 10+ keys! But we started with the formula anyway. It ended up looking something like this…

const [state1, setState1] = useState(1)
const [state2, setState2] = useState(2)
const [state3, setState3] = useState(3)
const [state4, setState4] = useState(4)
const [state5, setState5] = useState(5)
const [state6, setState6] = useState(6)
const [state7, setState7] = useState(7)
const [state8, setState8] = useState(8)
const [state9, setState9] = useState(9)
const [state10, setState10] = useState(10)

I know. I’m thinking the same thing you are — there’s no way that’s easier or more efficient than a single state object. And I agree.

But aside from the practicality of all those state updaters, there’s some other issues that we only discovered when our components were fully refactored…

setState hook updaters don’t get batched if they’re in an asynchronous callback

Let’s break that down.

Imagine we have a function -

function batchedUpdates(var1, var2, var3) {
setState1(var1)
setState2(var2)
setState3(var3)
}

React will do us a favor and “batch” all three of those state updates into one update and only one render.

However, in the current version of React, setState ‘s which are asynchronously called are not batched.

So, this function —

function batchedUpdates(var1, var2, var3) {
setTimeout(() => {
setState1(var1)
setState2(var2)
setState3(var3)
}, 100)
}

Will cause three renders! Oops.

But! We have a solution. Enter — useReducer

React gives us a tool to solve this “complex state update” problem — and borrows a common pattern used in Redux called Reducers. With useReducer, we can give our function component a state object that is much easier to update in an efficient manner!

Consider our first state declaration above with 10+ useStates. This now turns into:

const reducer = (state, action) => {
switch(action.type) {
case 'update': {
return { ...state, ...action.value }
}
default : {
return state
}
}
const [state, dispatch] = useReducer(reducer, {
state1: 1,
state2: 2,
state3: 3,
state4: 4,
state5: 5,
state6: 6,
state7: 7,
state8: 8,
state9: 9,
state10: 10
}

This might not seem a lot cleaner at first glance, but useReducer allows us to update complex state trees in one action just like Redux allows. For example….

setState1(var1)
setState2(var2)
setState3(var3)

turns into

dispatch({
type: 'update',
value: {
state1: var1,
state2: var2,
state3: var3,
}
})

This prevents the performance hit that might occur if we need to update multiple state variables in an async callback! While more syntax, this allows us to manage our component state in a much more efficient manner.

Complication: Stale Values

Let’s take a look at this code.

const [songs, setSongs] = useState(props.allSongs)
const [currentSongId, setCurrentSongId] = useState('123id')
const [currentSong, setCurrentSong] = useState(findNewSong('123id'))

useEffect(() => {
setCurrentSong(findNewSong(currentSongId))
}, [currentSongId])
useEffect(() => {
setSongs(props.allSongs)
}, [props.allSongs])
function findNewSong(songId) {
songs.find(song => {
return song.id === songId
})
}

Seems to look good, right? Bug free?

Let’s look closer.

What happens if we have an active song and the props.allSongs updates? We will set our songs state but the current song will remain as it is. What if the song we had selected changes? We’ll have a stale value! currentSong will remain the same, because our first useEffect will not execute.

This is a concept with hooks called Exhaustive Dependencies. We need to make sure that any variables our useEffect hooks depend on are accounted for in the dependency array. If we fix our first useEffect to look like the following, we’ll be in the clear.

useEffect(() => {
setCurrentSong(findNewSong(currentSongId))
}, [currentSongId, songs])

Perfect. No more stale values :)

Conclusion: Think before you refactor

Here at Ombud, we looked back on our hooks-frenzy thinking, “did we really need to refactor that?” More often than not, the answer was “probably not”.

A few things to ask yourself before you refactor…

  1. Will refactoring make my component simpler?
  2. Does this component already work, bug-free?
  3. Does this refactor solve a problem, or does it create one to solve?

If all three answers to this question are no, you probably don’t need to refactor this component.

Just leave it how it is, React isn’t going to deprecate lifecycle methods anytime soon, if ever!

Interested in joining Ombud? We’re hiring!

--

--

Matt Kaufman

Matt is a thinker, lover and software engineer. He is driven to help create a more beautiful, sustainable and equitable world for all living beings to enjoy.