How to Optimize React Hooks Performance
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.
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.
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.
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.
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.
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.
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.
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).
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.
Tags: React, Hooks, Javascript, Performance