React Hooks: Memoization

Airbus A320 CGI © Max Hoek

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.

React Profiler: Flame Chart

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.