Introduction

A good number of people are excited about the addition of Hooks to React — I happen to be one of those people!

Once you get past the tutorials on how to get started with Hooks, your next concern becomes obvious: How do you actually refactor your existing application or components to use Hooks? What challenges are you likely to face?

The goal of this article is quite simple, but its approach is perhaps unique. For this topic, most writers would likely take an existing app and show you the steps to refactor it to use Hooks. That’s OK, but far from perfect.

Why? Well, every application is different, each with its own specific use cases and scenarios.

Instead of showing you how to refactor a single app to use React Hooks, I’ll walk you through some generic challenges that apply to a wide variety of application types. And don’t worry, I’ll start with the basic concerns before moving on to more advanced use cases.

Why refactor to use React Hooks?

I don’t intend to explain why you should consider refactoring your components to use Hooks. If you’re looking for some decent arguments, the official docs have some.

Prerequisites

This article assumes you have some knowledge of how React Hooks works. If you need a reminder of how Hooks work, check out this helpful introduction.

Once we get that out of the way, you’ll be ready to get started on the challenges (and solutions) you’ll face as you refactor your application to use React Hooks.

The first problem everyone faces: How to convert a class component to a function component

When you set out to refactor your application to use React Hooks, the first problem you’ll face happens to be the root from which other challenges stem.

The challenge is simple: How do you refactor your class components to function components without breaking any functionalities?

Well, let’s have a look at some of the most common cases you’ll encounter, starting with the easiest.

1. Class component without state or lifecycle methods

N.B., this GIF may be enough for more advanced readers to spot the difference in this refactoring from class to function components. For the sake of accessibility, and for others who need a little more context, I’ll explain and have the code written out as well.

This is the most basic case you’ll have: a class component that’s pretty much dumb. It just renders some JSX .

// before import React, {Component} from 'react'; class App extends Component { handleClick = () => { console.log("helloooooo") } render() { return <div> Hello World <button onClick={this.handleClick}> Click me! </button> </div> } } export default App

Refactoring this component is pretty straightforward. Here you go:

// after import React from 'react' function App() { const handleClick = () => { console.log("helloooooo") } return <div> Hello World <button onClick={handleClick}> Click me! </button> </div> } export default App

What’s different here?

No class keyword; replace with a JavaScript function

keyword; replace with a JavaScript function No this in a function component; replace with a JavaScript value in the function scope

That’s all — nothing major here. Let’s move on.

2. Class component with props, some default prop values, and propType declarations

This is another simple case where there isn’t a lot of overhead. Consider the following class component:

// before class App extends Component { static propTypes = { name: PropTypes.string } static defaultProps = { name: "Hooks" } handleClick = () => { console.log("helloooooo") } render() { return <div> Hello {this.props.name} <button onClick={this.handleClick}> Click me! </button> </div> } }

Upon refactoring, we have this:

function App({name = "Hooks"}) { const handleClick = () => { console.log("helloooooo") } return <div> Hello {name} <button onClick={handleClick}>Click me! </button> </div> } App.propTypes = { name: PropTypes.number }

The component looks a lot simpler as a functional component. The props become function parameters, default props are handled via the ES6 default parameter syntax, and static propTypes is replaced with App.propTypes . That’s about it!

3. Class component with state (single or few multiple keys)

The scenario gets more interesting when you have a class component with an actual state object. A lot of your class components will fall into this category or a slightly more complex version of this category.

Consider the following class component:

class App extends Component { state = { age: 19 } handleClick = () => { this.setState((prevState) => ({age: prevState.age + 1})) } render() { return <div> Today I am {this.state.age} Years of Age <div> <button onClick={this.handleClick}>Get older! </button> </div> </div> } }

The component only keeps track of a single property in the state object. Easy enough!

We can refactor this to use the useState Hook, as shown below:

function App() { const [age, setAge] = useState(19); const handleClick = () => setAge(age + 1) return <div> Today I am {age} Years of Age <div> <button onClick={handleClick}>Get older! </button> </div> </div> }

That looks a lot simpler!

If this component had more state object properties, you could use multiple useState calls. That’s perfectly fine, as shown below:

function App() { const [age, setAge] = useState(19); const [status, setStatus] = useState('married') const [siblings, setSiblings] = useState(10) const handleClick = () => setAge(age + 1) return <div> Today I am {age} Years of Age <div> <button onClick={handleClick}>Get older! </button> </div> </div> }

This is the most basic of concerns, but if you need more examples, you’ll find them in this helpful guide.

Making trade-offs for incremental Hooks adoption

While it sounds great to rewrite your applications/components to use Hooks, it does come at a cost — time and manpower being the forerunners.

If you happen to be working on a large codebase, you may need to make some trade-offs in the earlier stages of Hooks adoption. One such scenario is described below.

Consider the following component:

const API_URL = "https://api.myjson.com/bins/19enqe"; class App extends Component { state = { data: null, error: null, loaded: false, fetching: false, } async componentDidMount() { const response = await fetch(API_URL) const { data, status } = { data: await response.json(), status: response.status } // error? if (status !== 200) { return this.setState({ data, error: true, loaded: true, fetching: false, }) } // no error this.setState({ data, error: null, loaded: true, fetching: false, }) } render() { const { error, data } = this.state; return error ? <div> Sorry, and error occured :( </div> : <pre>{JSON.stringify(data, null, ' ')}</pre> } }

This component makes a request to a remote server to fetch some data when it is mounted, then it sets state based on the results.

I don’t want you to focus on the async logic going on in there, so here’s where your attention should be: the setState calls.

class App extends Component { ... async componentDidMount() { ... if (status !== 200) { return this.setState({ data, error: true, loaded: true, fetching: false, }) } this.setState({ data, error: null, loaded: true, fetching: false, }) } render() { ... } }

The setState calls here take in an object with four properties. This is a just an example, but the generic case here would be that you have a component that makes setState calls with a lot of object properties.

Now, with React Hooks, you’d likely go ahead and split each object value into its separate useState calls. You could use an object with useState , but these properties are unrelated, and using object here may make it more difficult to break this up into independent custom Hooks later on.

So here’s what a refactor may look like:

... const [data, setData] = useState(null); const [error, setError] = useState(null); const [loaded, setLoading] = useState(false); const [fetching, setFetching] = useState(false); ...

Wait — that’s not all!

The this.setState calls will also have to be changed to look like this:

// no more this.setState calls - use updater functions. setData(data); setError(null); setLoading(true); fetching(false);

Yes, this works. However, if you had a lot of setState calls within the component, then you’ll write this multiple times or group them in another custom Hook.

Now, what if you wanted an incremental adoption of Hooks in your codebase, with fewer code changes while keeping a slightly similar setState signature? Would that be possible?

In this case, you do have to make a trade-off. If you’re working on a late codebase, this can easily happen! Here, we’ll introduce the useReducer Hook.

useReducer has the following signature:

const [state, dispatch] = useReducer(reducer)

reducer is a function that takes a state and action and returns a newState .

const [state, dispatch] = useReducer((state, action) => newState)

The newState returned from the reducer is then consumed by the component via the state variable.

If you’ve used Redux before, then you know your action must be an object with a certain type property. However, this is not the case with useReducer . Instead, the reducer function takes in state and some action , then returns a new state object.

We can take advantage of this and have a less painful refactoring, as shown below:

... function AppHooks() { ... const [state, setState] = useReducer((state, newState) => ( {...state, ...newState} )); setState({ data, error: null, loaded: true, fetching: false, }) }

What’s going on above?

You see, instead of changing a lot of the this.setState calls everywhere in the component, we’ve chosen to take a simpler, incremental approach that doesn’t involve a lot of code change.

Instead of this.setState({data, error: null, loaded: null, fetching: false}) , just remove the this. , and the setState call will still work, but with Hooks!

Here’s what makes that possible:

const [state, setState] = useReducer((state, newState) => ( { ...state, ...newState } ));

When you attempt to update state, whatever is passed into setState (which is typically called dispatch ) is passed on to the reducer as the second argument. We call this newState .

Now, instead of doing some fancy switch statement (as in Redux), we just return a new state object that overrides the previous state with the new values passed in — kind of how setState works, i.e., by updating state properties as opposed to replacing the entire object.

With this solution, it’s easier to embrace an incremental Hooks adoption in your codebase — one without a lot of code change and with a similar setState signature.

Here’s the full refactored code, with less code change:

function AppHooks() { const initialState = { data: null, error: null, loaded: false, fetching: false, } const reducer = (state, newState) => ({ ...state, ...newState }) const [state, setState] = useReducer(reducer, initialState); async function fetchData() { const response = await fetch(API_URL); const { data, status } = { data: await response.json(), status: response.status } // error? if (status !== 200) { return setState({ data, error: true, loaded: true, fetching: false, }) } // no error setState({ data, error: null, loaded: true, fetching: false, }) } useEffect(() => { fetchData() }, []) const { error, data } = state return error ? Sorry, and error occured :( : <pre>{JSON.stringify(data, null, ' ')}</pre> }

Simplifying lifecycle methods

Another common challenge you’ll face will be refactoring the logic in your component’s componentDidMount , componentWillUnmount , and componentDidUpdate lifecycle methods.

The useEffect Hook is the perfect place to have this logic extracted. By default, the effect function within useEffect will be run after every render. This is common knowledge if you’re familiar with Hooks.

import { useEffect } from 'react' useEffect(() => { // your logic goes here // optional: return a function for canceling subscriptions return () = {} })

So what’s probably new here?

An interesting feature of the useEffect Hook is the second argument you could pass in: the dependency array.

Consider the example of an empty dependency array, shown below:

import { useEffect } from 'react' useEffect(() => { }, []) // 👈 array argument

Passing an empty array here will have the effect function run only when the component mounts and cleaned when it unmounts. This is ideal for cases where you want to track or fetch some data when the component mounts.

Here’s an example where you pass a value to the dependency array:

import { useEffect } from 'react' useEffect(() => { }, [name]) // 👈 array argument with a value

The implication here is that the effect function will be invoked when the component mounts, and again any time the value of the name variable changes.

Comparing useEffect object values

The useEffect Hook takes in a function argument that possibly performs some side effects.

useEffect(doSomething)

However, the useEffect Hook also takes in a second argument: an array of values that the effect in the function depends on. For example:

useEffect(doSomething, [name])

In the code above, the doSomething function will only be run when the name value changes. This is a very useful feature, since you may not want the effect to run after every single render, which is the default behavior.

However, this poses another concern. In order for useEffect to call the doSomething function only when name has changed, it compares the previous name value to its current value, e.g., prevName === name .

This works great for primitive JavaScript value types.

But what if name was an object? Objects in JavaScript are compared by reference! Technically, if name was an object, then it’ll always be different on every render, so the check prevName === name will always be false.

By implication, the doSomething function will be run after every single render — which could be a performance concern depending on your application type. Are there any solutions to this?

Consider the trivial component below:

function RandomNumberGenerator () { const name = 'name' useEffect( () => { console.log('Effect has been run!') }, [name] ) const [randomNumber, setRandomNumber] = useState(0) return ( <div> <h1>{randomNumber}</h1> <button onClick={() => { setRandomNumber(Math.random()) }} > Generate random number! </button> </div> ) }

This component renders a button and a random number. Upon clicking the button, a new random number is generated.

Note that the useEffect Hook has the effect dependent on the name variable.

useEffect(() => { console.log("Effect has been run!") }, [name])

In this example, the name variable is a simple string. The effect will run when the component mounts; hence, console.log("Effect has been run!") will be invoked.

On subsequent renders, a shallow comparison will be made, e.g., is prevName === name where prevName represents the previous value of the name before a new render.

Strings are compared by value, so "name" === "name" is always true. Thus, the effect won’t be run.

Consequently, you get the log output Effect has been run! just once!

Now, change the name variable to an object.

function RandomNumberGenerator() { // look here 👇 const name = {firstName: "name"} useEffect(() => { console.log("Effect has been run!") }, [name]) const [randomNumber, setRandomNumber] = useState(0); return {randomNumber} { setRandomNumber(Math.random()) }}>Generate random number! }

In this case, after the first render, the shallow check is carried out again. However, since objects are compared by reference — not by value — the comparison fails. For example, the following expression returns false :

{firstName: "name"} === {firstName: "name"}

Consequently, the effect is run after every render, and you get a lot of logs.

How can we stop this from happening?

Solution 1: Use JSON.stringify

Here’s what this solution looks like:

...useEffect(() => { console.log("Effect has been run!") }, [JSON.stringify(name)])

By using JSON.stringify(name) , the value being compared is now a string and, as such, will be compared by value.

This works, but proceed with caution. Only use JSON.stringify on objects with not-so-complex values, and with easily serializable data types.

Solution 2: Use a manual conditional check

This solution involves keeping track of the previous value — in this case, name — and doing a deep comparison check on its current value.

It’s a little more code, but here’s how that works:

// the isEqual function can come from anywhere // - as long as you perform a deep check. // This example uses a utility function from Lodash import {isEqual} from 'lodash' function RandomNumberGenerator() { const name = {firstName: "name"} useEffect(() => { if(!isEqual(prevName.current, name)) { console.log("Effect has been run!") } }) const prevName = useRef; useEffect(() => { prevName.current = name }) const [randomNumber, setRandomNumber] = useState(0); return <div> <h1> {randomNumber} </h1> <button onClick={() => { setRandomNumber(Math.random()) }}> Generate random number! </button> </div> }

Now, we check if the values aren’t equal before running the effect:

!isEqual(prevName.current, name)

But what’s prevName.current ? With Hooks, you can use the useRef Hook to keep track of values. In the example above, the bit of code responsible for that is:

const prevName = useRef; useEffect(() => { prevName.current = name })

This keeps track of the previous name used in the earlier useEffect Hook. I know this can be confusing to understand, so I’ve included a well-annotated version of the full code below:

/** * To read the annotations correctly, read all turtle comments first 🐢 // - from top to bottom. * Then come back to read all unicorns 🦄 - from top to bottom. */ function RandomNumberGenerator() { // 🐢 1. The very first time this component is mounted, // the value of the name variable is set below const name = {firstName: "name"} // 🐢 2. This hook is NOT run. useEffect only runs sometime after render // 🦄 6. After Render this hook is now run. useEffect(() => { // 🦄 7. When the comparison happens, the hoisted value // of prevName.current is "undefined". // Hence, "isEqual(prevName.current, name)" returns "false" // as {firstName: "name"} is NOT equal to undefined. if(!isEqual(prevName.current, name)) { // 🦄 8. "Effect has been run!" is logged to the console. //console.log("Effect has been run!") } }) // 🐢 3. The prevName constant is created to hold some ref. const prevName = useRef; // 🐢 4. This hook is NOT run // 🦄 9. The order of your hooks matter! After the first useEffect is run, // this will be invoked too. useEffect(() => { // 🦄 10. Now "prevName.current" will be set to "name". prevName.current = name; // 🦄 11. In subsequent renders, the prevName.current will now hold the same // object value - {firstName: "name"} which is alsways equal to the current // value in the first useEffect hook. So, nothing is logged to the console. // 🦄 12. The reason this effect holds the "previous" value is because // it'll always be run later than the first hook. }) const [randomNumber, setRandomNumber] = useState(0) // 🐢 5. Render is RUN now - note that here, name is equal to the object, // {firstName: "name"} while the ref prevName.current holds no value. return {randomNumber} { setRandomNumber(Math.random()) }}> Generate random number! }

Solution 3: Use the useMemo Hook

This solution is pretty elegant, in my opinion. Here’s what it looks like:

function RandomNumberGenerator() { // look here 👇 const name = useMemo(() => ({ firstName: "name" }), []) useEffect(() => { console.log("Effect has been run!") }, [name]) const [randomNumber, setRandomNumber] = useState(0) return {randomNumber} { setRandomNumber(Math.random()) }}> Generate random number! }

The useEffect Hook still depends on the name value, but the name value here is memoized, provided by useMemo .

const name = useMemo(() => ({ firstName: "name" }), [])

useMemo takes in a function that returns a certain value — in this case, the object {firstName: "name"} .

The second argument to useMemo is an array of dependencies that works just like those in useEffect . If no array is passed, then the value is recomputed on every render.

Passing an empty array computes the value on mounting the component without recomputing the value across renders. This keeps the name value the same (by reference) across renders.

Owing to the explanation above, the useEffect Hook now works as expected, without calling the effect multiple times, even though name is an object.

name is now a memoized object with the same reference across renders.

...useEffect(() => { console.log("Effect has been run!") }, [name]) // 👈 name is memoized!

Your test now breaks because of useEffect ?

One of the more disturbing issues you may face when refactoring your app (or components) to use Hooks is that some of your older tests may now fail — for seemingly no reason.

If you find yourself in this position, understand that there is indeed a reason for the failed tests, sadly.

With useEffect , it is important to note that the effect callback isn’t run synchronously — it runs at a later time after render. Thus, useEffect is not quite componentDidMount + componentDidUpdate + componentWillUnmount .

Owing to this “async” behavior, some (if not all) of your older tests may now fail when you introduce useEffect .

Any solutions?

Using the act utility from react-test-utils helps a lot in these use cases. If you use react-testing-library for your tests, then it integrates pretty well (under the hood) with act . With react-testing-library, you still need to wrap manual updates, such as state updates or firing events, within your test into act .

act(() => { /* fire events that update state */ }); /* assert on the output */

There’s an example in this discussion. Making async calls within act ? Here’s a discussion on that as well.

Wait, what?

You probably think I’ve glossed over the solution to using the act test utility function. I was going to write a more detailed explanation, but Sunil Pai beat me to it. If you think the React docs didn’t explain the concept well — and I agree — you’ll find amazing examples of how act works in this repo.

Another issue related to failing tests comes up if you use a testing library like Enzyme and have a couple implementation details in your tests, e.g., calling methods such as instance() and state() . In these cases, your tests may fail just by refactoring your components to functional components.

A safer way to refactor your render props API

I don’t know about you, but I use the render props API all over the place. Refactoring a component that uses a render props API to use Hooks-based implementation is no big deal. There’s one little gotcha, though.

Consider the following component that exposes a render prop API:

class TrivialRenderProps extends Component { state = { loading: false, data: [] } render() { return this.props.children(this.state) } }

This is a contrived example, but good enough! Here’s an example of how this component will be used:

function ConsumeTrivialRenderProps() { return <TrivialRenderProps> {({loading, data}) => { return <pre> {`loading: ${loading}`} <br /> {`data: [${data}]`} </pre> }} </TrivialRenderProps> }

Rendering the ConsumeTrivialRenderProps component just displays the value of the loading and data values as received from the render props API.

So far, so good!

The problem with render props is that it can make your code look more nested than you’d like. Thankfully, as mentioned earlier, refactoring the TrivialRenderProps component to a Hooks implementation isn’t a big deal.

To do this, you just wrap the component implementation within a custom Hook and return the same data as before. When done right, here’s how the refactored Hooks API will be consumed:

function ConsumeTrivialRenderProps() { const { loading, setLoading, data } = useTrivialRenderProps() return <pre> {`loading: ${loading}`} <br /> {`data: [${data}]`} </pre> }

Looks a lot neater!

Now here’s the custom Hook useTrivialRenderProps :

function useTrivialRenderProps() { const [data, setData] = useState([]) const [loading, setLoading] = useState(false) return { data, loading, } }

And that’s it!

// before class TrivialRenderProps extends Component { state = { loading: false, data: [] } render() { return this.props.children(this.state) } } // after function useTrivialRenderProps() { const [data, setData] = useState([]) const [loading, setLoading] = useState(false) return { data, loading, } }

So what’s the problem here?

When working on a large codebase, you may have a certain render prop API consumed in many different places. Changing the implementation of the component to use Hooks means you do have to change how the component is consumed in many different places.

Is there some trade-off we can make here? Absolutely!

You could refactor the component to use Hooks, but also expose a render props API. By doing this, you can incrementally adopt Hooks across your codebase instead of having to change a lot of code all at once.

Here’s an example:

// hooks implementation function useTrivialRenderProps() { const [data, setData] = useState([]) const [loading, setLoading] = useState(false) return { data, loading, } } // render props implementation const TrivialRenderProps = ({children, ...props}) => children(useTrivialRenderProps(props)); // export both export { useTrivialRenderProps }; export default TrivialRenderProps;

Now, by exporting both implementations, you can incrementally adopt Hooks in your entire codebase, as both the former render props consumers and newer Hook consumers will work perfectly!

// this will work 👇 function ConsumeTrivialRenderProps() { return <TrivialRenderProps> {({loading, data}) => { return <pre> {`loading: ${loading}`} <br /> {`data: [${data}]`} </pre> }} </TrivialRenderProps> } // so will this 👇 function ConsumeTrivialRenderProps() { const { loading, setLoading, data } = useTrivialRenderProps() return <pre> {`loading: ${loading}`} <br /> {`data: [${data}]`} </pre> }

What I find interesting here is that the new render props implementation uses Hooks under the Hooks as well.

// render props implementation const TrivialRenderProps = ({children, ...props}) => children(useTrivialRenderProps(props));

Handling state initializers

It is not uncommon to have class components where certain state properties are initialized based off of some computation. Here’s a basic example:

class MyComponent extends Component { constructor(props) { super(props) this.state = { token: null } if (this.props.token) { this.state.token = this.props.token } else { token = window.localStorage.getItem('app-token'); if (token) { this.state.token = token } } } }

This is a simple example, but it shows a generic problem. It’s possible that as soon as your component mounts, you set some initial state in the constructor based on some computations.

In this example, we check if there’s a token prop passed in or if there’s an app-token key in local storage, and then we set state based off that. Upon refactoring to Hooks, how do you handle such logic to set initial state?

Perhaps a lesser-known feature of the useState Hook is that the initialState parameter you pass to the useState Hook — useState(initialState) — may also be a function!

Whatever you return from this function is then used as the initialState . Here’s what the component looks like after it’s been refactored to use Hooks:

function MyComponent(props) { const [token, setToken] = useState(() => { if(props.token) { return props.token } else { tokenLocal = window.localStorage.getItem('app-token'); if (tokenLocal) { return tokenLocal } } }) }

Technically, the logic stays almost the same. What’s important here is that you can use a function in useState if you need to initialize state based off of some logic.

Conclusion

Refactoring your application to use Hooks isn’t something you must do. Weigh the options for yourself and your team. If you choose to refactor your components to use the new Hooks API, then I hope you’ve found some great tips in this article.

Catch you later!

Full visibility into production React apps Debugging React applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, Debugging React applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket LogRocket is like a DVR for web apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more. The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores. Modernize how you debug your React apps — start monitoring for free.