How to Optimize React Hooks Performance

Updated at August 21, 2020

Hooks are one of React's most influential features that redefine the way we write components in React. They let you use the majority of class-based component features within a functional component, which leads us to smaller boilerplate and easier to test.

To get the most out of this feature, let's take a look at how we can optimize our React apps performance that uses hooks.

Getting started

Let's get started by initializing a React application with Create React App.

$ npx create-react-app app-name

Then, let's rewrite the entire App.js file.

src/App.js
import React, { useState } from "react"
import Counter from "./Counter"

export default function App() {
	const [value, setValue] = useState("")

	return (
		<div>
			<input
				type="text"
				onChange={(e) => setValue(e.target.value)}
				value={value}
			/>
			<Counter />
		</div>
	)
}

In the App component, we have a simple text input that synchronize its value to the value state we created using useState hook. We are also displaying the Counter component imported from ./src/Counter.js file, which we are about to create.

src/Counter.js
import React, { useState, useRef } from "react"

export default function Counter() {
	const [counter, setCounter] = useState(0)
	const renders = useRef(0)

	return (
		<div>
			<div>Counter: {counter}</div>
			<div>Renders: {renders.current++}</div>
			<button onClick={() => setCounter(counter + 1)}>Increase Counter</button>
		</div>
	)
}

In the Counter component, we have a counter state which will increase each time we click the "Increase Counter" button, and a renders ref which will tell us how many times this component is being rerendered.

For reference, I published the entire source code of this project on my GitHub. Feel free to poke around or clone it onto your computer.

$ git clone https://github.com/rahmanfadhil/learn-hooks.git

The unnecessary rerender

If you try to run this app on your machine, you can see that when you press the "Increase Counter" button several times, the render counter shows the same number as the counter state. Meaning that the Counter component is being rerendered whenever our counter state changed.

But when you type in the App component's text input, you will see that the renders counter is also increased. Which means that our Counter component rerendered whenever our text input state changed, which is unnecessary because there's nothing we want to change inside the Counter component.

So, how can we fix it?

Memoizing components

React version 16.6 (and higher) comes with a higher order component called React.memo, which is very similar to PureComponent but for functional component instead of classes.

This allows us to memoize our component so that we can control when our components should rerender. Let's wrap our Counter component with React.memo to prevent unnecessary rerenders.

src/Counter.js
import React, { useState, useRef } from "react"

export default React.memo(() => {
	const [counter, setCounter] = useState(0)
	const renders = useRef(0)

	return (
		<div>
			<div>Counter: {counter}</div>
			<div>Renders: {renders.current++}</div>
			<button onClick={() => setCounter(counter + 1)}>Increase Counter</button>
		</div>
	)
})

Easy right? Let's checkout out our new app and you'll see that the Counter component doesn't get re-rendered when we type in the text input.

When a component passed a prop to a memoized component, the memoized component will check wether the prop is changed or not each time the component gets rerendered. To prove this, let's try to pass a prop to Counter component.

src/App.js
import React, { useState } from "react"
import Counter from "./Counter"

export default function App() {
	const [value, setValue] = useState("")

	return (
		<div>
			<input
				type="text"
				onChange={(e) => setValue(e.target.value)}
				value={value}
			/>
			<Counter greeting="Hello world!" />
		</div>
	)
}

Here, we are passing a string in the greeting prop to the Counter component. If you run the app, you'll see that our app will run just like before. Because the memoized component only updates itself when the props changed.

Memoizing functions

React.memo is awesome, but it comes with a drawback if you take it with a grain of salt. It works great with basic data types such as strings, numbers, or boolean. But the component is not clever enough to check the differences between two functions or objects.

src/App.js
import React, { useState } from "react"
import Counter from "./Counter"

export default function App() {
	const [value, setValue] = useState("")

	return (
		<div>
			<input
				type="text"
				onChange={(e) => setValue(e.target.value)}
				value={value}
			/>
			<Counter
				addHello={() => setValue(value + "Hello!")}
				myObject={{ key: "value" }}
			/>
		</div>
	)
}

You will notice that your Counter rerender whenever you type something in the text field.

To tackle this problem, we can use the useCallback hook to memoize our function that we want to pass through the props. It returns a memoized version of our function that only changes if one of the dependencies have changed.

Let's implement this in our app.

src/App.js
import React, { useState, useCallback } from "react"
import Counter from "./Counter"

export default function App() {
	const [value, setValue] = useState("")

	const addHello = useCallback(() => {
		setValue(value + "Hello!")
	}, [value])

	return (
		<div>
			<input
				type="text"
				onChange={(e) => setValue(e.target.value)}
				value={value}
			/>
			<Counter addHello={addHello} myObject={{ key: "value" }} />
		</div>
	)
}

This method is very useful when you have more than one state hook. The memoized functions are updated only when the chosen state changed. To prove this, let's add another input field.

src/App.js
import React, { useState, useCallback } from "react"
import Counter from "./Counter"

export default function App() {
	const [value, setValue] = useState("")
	const [newValue, setNewValue] = useState("")

	const addHello = useCallback(() => {
		setValue(value + "Hello!")
	}, [value])

	return (
		<div>
			<input
				type="text"
				onChange={(e) => setValue(e.target.value)}
				value={value}
			/>
			<input
				type="text"
				onChange={(e) => setNewValue(e.target.value)}
				value={newValue}
			/>
			<Counter addHello={addHello} myObject={{ key: "value" }} />
		</div>
	)
}

Now, when we type in the new text field, the Counter component doesn't rerender itself, because our memoized function will ignore everything that happens in the newValue state.

Memoizing objects

Now we know how to memoize our function, but there is one last thing you should know about memoizing.

Currently, our Counter component is still being rerendered whenever the state has changed. Its because the myObject props are still not memoized yet. You can use useMemo hook to memoize a any value (including objects) by passing a "create" function and an array of dependencies. The value will only recompute when one of the dependencies has changed (just like useCallback hook).

src/App.js
import React, { useState, useCallback } from "react"
import Counter from "./Counter"

export default function App() {
	const [value, setValue] = useState("")
	const [newValue, setNewValue] = useState("")

	const addHello = useCallback(() => setValue(value + "Hello!"), [value])
	const myObject = useMemo(() => ({ key: "value" }), [])

	return (
		<div>
			<input
				type="text"
				onChange={(e) => setValue(e.target.value)}
				value={value}
			/>
			<input
				type="text"
				onChange={(e) => setNewValue(e.target.value)}
				value={newValue}
			/>
			<Counter addHello={addHello} myObject={myObject} />
		</div>
	)
}

By adding these changes, you're now able to pass props to a memoized component without sacrificing performance.

Profile picture

Abdurrahman Fadhil

I'm a software engineer specialized in iOS and full-stack web development. If you have a project in mind, feel free to contact me and start the conversation.