How to Optimize React Hooks

How to utilize React.memo, useMemo, and useCallback efficiently.

Tadeáš Peták
Bits and Pieces

--

If you code in React, chances are hooks are your daily bread. I find that no matter how well I believe I know useCallback, useMemo, and React.memo, this trio always merits an extra slice of my attention. Care to embark upon a little dissection with me?

Posts that prescribe when memoization should and should not be used have always left me feeling the way I used to feel about learning by rote at school: grasping for straws and hoping I wouldn’t forget. Which I always did anyway.

I want to focus instead on the basic mechanisms in React and the tools it gives us to communicate our wishes. That way, you can always infer all the rules for yourself. As a side effect, I hope we’ll all memoize less with a more substantial impact when we choose to do so.

(This post assumes at least rudimentary familiarity with hooks. If that’s not the case, try Hooks at a Glance or Quick Intro to Hooks.)

The Lingo

When we say “component”, we typically mean “function component”: a function which returns a component. This is why you type them as const Table: React.FC = … , after all.

A re-render, then, means calling the function again, receiving a fresh, crisp component in return. In general, this is necessary in two cases:

  1. Component’s internal state has changed, for example via a setState call.
  2. Function’s arguments passed in by the caller (props) have been updated.

When this process is kicked off in a parent, it trickles down to all its child components. Makes sense, right? If something changed in a Table, its Rows and HeaderCells might need to be updated, too 🤷.

(Once React traverses all the way down to the bottom of your component tree, it diffs the result against the current DOM to determine if and how the UI should be updated. This diffing process is known as reconciliation.)

Noticed that “might” two paragraphs back? In React, optimisation often consists in telling the framework when re-rendering is not required. Whenever a render ends up happening, we try to help out by saying what does not need re-computing. In both these cases, memoization turns out to be the key.

Memoization

This fancy word refers to storing a result of a function call and returning the cached value when the input stays the same.

We all do this every day. When you ask me for the factorial of 6, I will need to wade through some multiplication to get there. When you ask me again in a minute, however, I will simply know it’s still 720.

Sweet, I’ve memoized. 💪

What follows is a pretty contrived piece of code, but it will serve our purpose. There’s a computationally somewhat expensive part — the factorial — and an independent bit of the app which changes often, the mouse coordinates.

Every setCoords and setInput leads to a change in the component’s internal state, which calls for a re-render to keep things fresh. We know now this amounts to running the component function again.

Even if only mouse coordinates have changed, everything inside this function must be evaluated, including the factorial. This is why you’ll see getting factorial log every time you move your mouse (here’s a sandbox). Here, we don’t really care about such wasteful computations, but what if we did?

useMemo

In that case, we could tell React that factorial only needs to be recomputed when the input changes. This is achieved via useMemo:

Via the second argument, the dependency array, we’re letting React in on a secret: “Unless the input has changed, the factorial stays the same.”

Next time you move your mouse, React checks the input value. If it’s different, it uses the recipe you’ve given it; otherwise, it returns the previous result.

Nice, now React has memoized, too. 💪

Can we do this for other things than simple values?

React.memo and useCallback

In the next example, we render a grid of rectangles containing their [i,j] coordinates inside the grid. When we hover over a square, we’d like its coordinates displayed in the main component.

As in the previous case, we track mouse coordinates to simulate another part of our app that makes the whole thing re-render often.

Every single time the mouse coords are updated, React must re-render 10,000 rectangles. No wonder you see your browser struggling when you move your mouse around (check out this sandbox).

What to do here?

The first step is to let React in on a secret again: “If the props of our Grid haven’t updated, don’t bother re-rendering it.” In other words, we’re telling the framework our Grid is a pure component — given identical props, the result is exactly the same. Enter React.memo.

There’s no dependency array in React.memo, it’s always props passed to our Grid compared to the previous ones. If they are identical, the result of the previous render is used — the only thing that runs in such cases is the comparison itself. For this purpose, React uses Object.is by default, but you can supply your own function as a second argument to React.memo.

Are we done after we’ve run this appallingly simple magic trick? If you give this a try, you’ll see your browser still struggling, implying it still renders 10,000 squares on each update of the mouse coordinates.

But why?

I recommend to make friends with the Profiler in React Dev Tools, it’s pretty indispensable.

The props we are passing to MemoizedGrid are to blame here. The size is not an issue, that’s just a number, but the onHover is a function which means… yep, the good old referential equality is biting us in our butt again.

When React executes our function to re-render the component, onHover is, naturally, reassigned. Although it’s the same function, it’s a completely different object. I mean, have you never been surprised by the following?

Referential (in)equality in JS.

In other words, React does execute a props check for our Grid, but it yields a verdict we don’t want.

useCallback

To fix this, we need the last communication device we have for memoization. useCallback is like useMemo for functions: it needs a recipe for a function, and a dependency array indicating when the recipe should be executed.

The code above ensures that onHover always references the same object. The props equality check in React.memo passes and, when you move your mouse, the app is smooth at last.

Yay 🎉

If you care for numbers, updates of our little app are about 150x faster with React.memo. Not bad for two extra lines of code, right? You can benchmark yourself directly in the sandbox.

Numbers estimated by React Component Benchmark.

Pros & Cons

I believe this gives us all the tools we need to answer the common questions that plague this trio of functions.

Cost 💰

You’ve seen what the usage looks like: an extra function call, a dependency array to take care of.

For us developers, these come with impeded readability and a burden of future maintenance. The framework, then, must keep track of the latest references and values to be able to make an equality check and supply the previous results where appropriate. That equality check comes at a cost, too.

As a Last Resort

For this reason, I often look at memoization as my last resort. What are some other things you might try before using this powerful concept?

  1. Keep non-reactive functions outside the component. Check out the getFactorial from the first example. Its referential stability might have been secured with useCallback, but there’s absolutely no reason it should ever live inside a component. When stationed outside, it never changes, no magic involved.
  2. Try restructuring your app. If we separated our Grid and the mouse coords business into different components, they could live side by side without any need for memoization. In other situations, you might want to consider the old trick of lifting expensive components into parents and passing them down as props. If you’re not familiar with this technique, give this post by Kent C. Dodds a read, it might blow your mind.
  3. Trust the browser. A lot of brain power has been poured into optimising React and JS. They’re pretty fast as they come.

Telltale Signs

When your app is struggling, it’s obviously a good time to look into what’s being rendered when. In some cases, you might even anticipate the problems before they come.

In particular, it pays off to pay attention when something large, or a lot of something, re-renders often. This is frequently the same case rephrased: lots of Rows make the Table large. Even in such obvious cases, keep being critical though! Memoizing individual rows might come with marginal benefits, if any, since React must compare props for each row. If you can memoize the entire table, like we did, that’s a different story indeed.

Once you do decide to memoize, it will likely seep into other places in your code. We’ve seen this above: once you go for React.memo, you might have to wrap certain things in useCallback. Keep revisiting your understanding to justify the extra code you’re putting in.

That’s all I’ve got for today, folks. Hope you’ve enjoyed the ride and learnt something new 🤞 Constructive feedback in particular is intensely welcome, but I’ll be glad for any kind. Have a marvellous day!

(Links: This post on profiling, tips on when to useMemo and useCallback, how and when to use React useCallback, explanation of reconciliation, and the aforementioned article on optimizing re-renders by restructuring were all well worth my time.)

Build composable frontend and backend

Don’t build web monoliths. Use Bit to create and compose decoupled software components — in your favorite frameworks like React or Node. Build scalable and modular applications with a powerful and enjoyable dev experience.

Bring your team to Bit Cloud to host and collaborate on components together, and greatly speed up, scale, and standardize development as a team. Start with composable frontends like a Design System or Micro Frontends, or explore the composable backend. Give it a try →

Learn More

--

--

Building a tiny house when not coding. Huge fan of yoga, books, and the outdoors.