React Hooks: Memoization
React Hooks make our life so much better in almost every way. But the minute performance becomes an issue, things get slightly tricky. You can write blazing fast applications using Hooks, but before you do that, there are a thing or two that you should be aware of.
Should you memoize?
React is plenty fast for most use cases. If your application is fast enough and you don’t have any performance problems, this article is not for you. Solving imaginary performance problems is a real thing, so before you start optimizing, make sure you are familiar with React Profiler
.
If you’ve identified scenarios where rendering is slow, memoization is probably the best bet.
React.memo
is a performance optimization tool, a higher order component. It’s similar to React.PureComponent
but for function components instead of classes. If your function component renders the same result given the same props, React will memoize, skip rendering the component, and reuse the last rendered result.
By default it will only shallowly compare complex objects in the props object. If you want control over the comparison, you can also provide a custom comparison function as the second argument.
No memoization
Let’s consider an example where we don’t use memoization and why that might cause performance problems.
function List({ items }) {
log('renderList');
return items.map((item, key) => (
<div key={key}>item: {item.text}</div>
));
}export default function App() {
log('renderApp'); const [count, setCount] = useState(0);
const [items, setItems] = useState(getInitialItems(10)); return (
<div>
<h1>{count}</h1>
<button onClick={() => setCount(count + 1)}>
inc
</button>
<List items={items} />
</div>
);
}
Example 1: Live Demo
Every time inc
is clicked, both renderApp
and renderList
are logged, even though nothing has changed for List
. If the tree is big enough, it can easily become performance bottleneck. We need to reduce number of renders.
Simple memoization
const List = React.memo(({ items }) => {
log('renderList');
return items.map((item, key) => (
<div key={key}>item: {item.text}</div>
));
});export default function App() {
log('renderApp'); const [count, setCount] = useState(0);
const [items, setItems] = useState(getInitialItems(10)); return (
<div>
<h1>{count}</h1>
<button onClick={() => setCount(count + 1)}>
inc
</button>
<List items={items} />
</div>
);
}
Example 2: Live Demo
In this example memoization works properly and reduces number of renders. During mount renderApp
and renderList
are logged, but when inc
is clicked, only renderApp
is logged.
Memoization & callback
Let’s make a small modification and add inc
button to all List
items. Beware, passing callback to memoized component can cause subtle bugs.
function App() {
log('renderApp');
const [count, setCount] = useState(0);
const [items, setItems] = useState(getInitialItems(10));
return (
<div>
<div style={{ display: 'flex' }}>
<h1>{count}</h1>
<button onClick={() => setCount(count + 1)}>
inc
</button>
</div>
<List
items={items}
inc={() => setCount(count + 1)}
/>
</div>
);
}
Example 3: Live Demo
In this example, our memoization fails. Since we are using inline lambda, new reference is created for each render, making React.memo
useless. We need a way to memoize the function itself, before we can memoize the component.
4. useCallback
Luckily, React has two built-in hooks for that purpose: useMemo
and useCallback
. useMemo is useful for expensive calculations, useCallback is useful for passing callbacks needed for optimized child components.
function App() {
log('renderApp');
const [count, setCount] = useState(0);
const [items, setItems] = useState(getInitialItems(10));
const inc = useCallback(() => setCount(count + 1));
return (
<div>
<div style={{ display: 'flex' }}>
<h1>{count}</h1>
<button onClick={inc}>inc</button>
</div>
<List items={items} inc={inc} />
</div>
);
}
Example 4: Live Demo
In this example, our memoization fails again.renderList
is called every time inc
is pressed. Default behavior of useCallback
is to compute new value whenever new function instance is passed. Since inline lambdas create new instance during every render, useCallback
with default config is useless here.
5. useCallback with input
const inc = useCallback(() => setCount(count + 1), [count]);
Example 5: Live Demo
useCallback
takes second argument, an array of inputs and only if those inputs change will useCallback
return new value. In this example, useCallback
will return new reference every time count
changes. Since count
changes during each render, useCallback
will return new value during each render. This code does not memoize as well.
useCallback with input of empty array
const inc = useCallback(() => setCount(count + 1), []);
Example 6: Live Demo
useCallback
can take an empty array as input, which will call inner lambda only once and memoize the reference for future calls. This code does memoize, one renderApp
will be called when clicking any button, main inc
button will work correctly, but inner inc
buttons will stop working correctly.
Counter will increment from 0
to 1
and it will stop after that. Lambda is created once, but called multiple times. Since count
is 0
when lambda is created, it behaves exactly as the code below:
const inc = useCallback(() => setCount(1), []);
The root cause of our problem is that we are trying to read and write from and to the state at the same time. We need API designed for that purpose. Fortunately for us, React provides two ways for solving the problem:
useState with functional updates
const inc = useCallback(() => setCount(c => c + 1), []);
Example 7: Live Demo
Setters returned by useState
can take function as an argument, where you can read previous value of a given state. In this example, memoization works correctly, without any bugs.
useReducer
const [count, dispatch] = useReducer(c => c + 1, 0);
Example 8: Live Demo
useReducer
memoization works exactly as useState
in this case. Since dispatch
is guaranteed to have same reference across renders, useCallback
is not needed, which makes code less error-prone to memoization related bugs.
useReducer vs useState
useReducer
is more suited for managing state objects that contain multiple sub-values or when the next state depends on the previous one. Common pattern of using useReducer
is with useContext
to avoid explicitly passing callbacks in a large component tree.
The rule of thumb I recommend is to mostly use useState
for data that does not leave the component, but if non-trivial two-way data exchange is needed between parent and descendants, useReducer
is a better choice.
To sum things up, React.memo
and useReducer
are best friends, React.memo
and useState
are siblings that sometimes fight and cause problems, useCallback
is the next-door neighbor you should always be cautious about.