18

I've just learned about React.memo() and was wondering, when would we NOT want that behavior? Wouldn't we always want components to only re-render if their props have been changed?

2
  • 1
    possible answer in: stackoverflow.com/questions/53074551/… -- many ppl's opinion is: maybe you should always use it Commented Nov 5, 2023 at 1:59
  • Also, to quote the official docs over on react.dev/reference/react/memo: "You should only rely on memo as a performance optimization. If your code doesn’t work without it, find the underlying problem and fix it first. Then you may add memo to improve performance." Commented Jun 16, 2024 at 23:32

2 Answers 2

26

It's because the memoization itself is not free and you shouldn't use it everywhere.


Caching is hard

Memoization is just a form of caching whereby previous results (i.e. previous render) of some function (i.e. the component itself) are reused if the inputs (i.e. the props) to that function didn't "change" when it is invoked again in such a way as to mean it should be re-evaluated for real.

This inevitably raises the challenge of "cache invalidation", which is a somewhat infamously irritating problem no matter what area of Software Engineering we are discussing.

In order for everything to work perfectly, the logic that "invalidates" that cache needs to be accurate. In the case of memoization, that's the logic that detects if the inputs (props) changed in such a way that a re-render should occur. There are some key things to consider:

  1. The "change detection" logic needs to encapsulate exactly what inputs (props) have to change and what changes about those props in order for the component to be re-rendered and subsequently produce a meaningful change that represents those new props.
  2. If the "change detection" logic incorrectly results in a "cache-hit" for some change in the props that actually should have been picked up as a "change," the results of the previous rerender would stick around, and now that component is no longer consistent with the inputs. This is a big source of confusion in React, which is a declarative/functional model where such an inconsistency breaks basic expectations. It would effectively "block" legitimate updates from propagating and leave your app in an unforeseen state that can be very confusing to debug and reproduce. This often occurs later on due to changes in the component, such as a new prop being added that wasn't then accounted for in the change detection logic.
  3. In the opposite scenario, if the "change detection" logic incorrectly detects a "change" that results in a rerender when actually that rerender didn't technically have to happen, then you essentially still have some version of the original problem you thought you were solving in the first place. This also often occurs later on due to changes in the component, and the memoization is actually a false sense of security.

But isn't that "change detection" logic really easy and implementable within the React runtime? Not in a meaningful way...


Universal memoization fallacy

Now, let's imagine we are React core developers. We want to reduce the burden on developers to configure memoization and improve performance for everyone.

We want to do this at runtime. More on compile time optimizations later.

We set our sights on React's memo function used to memoize components. At first, we will pretend useMemo and useCallback does not exist, and come back to those later. Let's think about memo as a tool in isolation as a toy thinking exercise.

Success criteria: "meaningful memoization"

Extrapolating from the above "cache invalidation" points, let's agree on what "meaningful" or "ideal" memoization looks like:

  1. REQUIREMENT 1. Components re-render when their inputs change in a way that requires a re-render to be consistent with those inputs.
  2. REQUIREMENT 2 Components do not re-render unnecessarily when their inputs change such that the previous render result is already consistent with the new inputs.

If we do not meet (1), we have a bug in our app. If we do not meet (2), we have some computation that is, strictly speaking, unnecessary.

An application without any memo() calls at all will always satisfy (1), but technically fail at (2) strictly speaking.

However, the failure to meet that target isn't usually all that severe anyway. This is discussed more under "Memoization can only mask the real issue". For now though, we will assume we want to achieve to it as part of the theoretical toy exercise.

Theoretical approach

Let's keep in mind React's memo has an optional second argument, arePropsEqual, which is a function that is passed the prev/next props when the parent component updates, and you must return true if the props didn't change in a way that means the component should re-render. Otherwise false. This is useful context moving forward.

Now let's continue to imagine we are React core developers. We put forward the idea of just wrapping every component in memo. We hypothesise this will boost performance at low cost, and reduce the burden on devs to memoize their components.

We would have no option but to do this in such a way that it is universal. That is:

  • We will have to wrap every component in memo. "Why not?" we might think.
  • Fundamentally, we'll have to use the default arePropsEqual implementation. The developer cant have any input into this, or what was the point?

By default, the default arePropsEqual implementation is to iterate through each prop and compare its prev and next values using Object.is.

Whilst technically "memoizing", this is often not "meaningful" by itself.

It works great where the props are primitive values, but as soon as they're objects (e.g. functions, arrays, etc.), it breaks down. You are comparing object references, and those change frequently in React, where immutable state changes are required.

You fundamentally can't compare props that are/contain functions/objects defined inside render in a meaningful way if the only data you have is that they have changed referentially. React can't know at runtime what the dependencies of that function were, as it only has the function itself through props. There is no language mechanism at runtime to get that information automatically.

Evaluating approach

So how did we do against the earlier requirements?

Analysing Requirement 1

At first glance, this looks like it will at least meet Requirement 1, and so this "apply memo everywhere" approach would do no harm at least?

Actually, no. React can't choose to do it by default because it doesn't know if every component fits the definition of a Pure Component with no side effects. If they did, it would break a lot of stuff that's out there. But you might be confident to do it in your codebase.

There is an argument that perhaps React should have mandated components be pure with no side effects from day one and applied this default memoization. But the reality is that codebases are imperfect, it's too late to go down that road, and you'd increase the learning curve for new folks. In addition, you might say that having the memoisation APIs up front like this and opt-in makes it more clear what is really happening.

However, you can choose to do this if you are sure all components are pure and that is a legitimate pattern that you could consider in your team. Especially if the problem is not one big performance problem in a limited area but is actually coming from the cumulative effect of many smaller renders.

Analysing Requirement 2

Many of those changes that the default arePropsEqual will detect, won't actually need to trigger a re-render, but will anyway. This violates Requirement 2.

On the flip side, totally preventing function/object changes from triggering a re-render would be dangerous as it can lead to very confusing bugs since the component now has an old instance of the function, which possibly now references stale scope.

Optimally, it would only re-render due to an object/function change if that change was needed in order for the object/function to no longer reference stale values.

But, at the framework level, it simply does not have enough information at this point in the code to make a more informed decision than "rerender when references changed".


Practical meaningful memoization

So how would we reach meaningful memoization? Well, we need two things:

  1. We, as developers, need to make sure our components are Pure Components.
  2. We, as developers, need to provide additional information about what changes are meaningful and require a re-render.

(1) is something React can not assume, unfortunately, due to legacy (no Pure Component requirement).

For (2), the "additional information" is essentially metadata that defines, for some piece of data, what other data it is is reliant on. I.e. we explicitly provide the dependencies of some data.

This fundamentally can not be worked out by React at runtime. It just sees a JS variable. There's no way for it to trace its origin or the functions that may have performed on it along the way, or the parameters of those functions.

So we can conclude that it is impossible to apply meaningful memoization universally to every react component "for free".

So we have to provide this information. React has different mechanisms for achieving this that can be used in various situations.

Custom arePropsEqual

Recall that the memo function has a second argument, arePropsEqual. By providing this customised function, we can provide additional logic.

That logic has access to only the new props and the previous ones. Which forces you to use a diffing pattern to identify relevant changes that should or shouldn't cause a re-render.

For example, we may perform a deep comparison on a prop that accepts an object. Or we could have complex rules about what set of changes to which props are considered "meaningful".

This is generally not recommended, as it is very easy to accidentally block updates that should have triggered a rerender. This leads to very hard to debug issues where the component now hold potentially stale references.

Can perfect logic be implemented for some cases? Yes. However, aligning it continuously with changes to the component itself is difficult and time-consuming. There is a high chance of accidentally introducing bugs. The more complex the component's props are, the more edge cases you need to account for.

So, in many cases, the tradeoff between introducing a potential whole new surface area for bugs that would have otherwise been impossible versus the rerenders is simply not worth it. You should only do it if it truly is. Otherwise, it's using a sledgehammer to crack a nut.

You should also be aware that in some cases, you might even find the compute cost of performing the props diff actually might be larger than the cost of render or be a sizeable portion of it.

useMemo and useCallback

useCallback is just sugar on useMemo, so we will just concentrate on useMemo for simplicity.

useMemo allows individual values to be memoised by way of a callback that returns the value. They also accept a list of dependencies. When any of the dependencies in that list referentially change, the value is recalculated.

It is not allowed to reference any props/state inside the callback body without adding them to the dependencies array. Linting rules will try to detect this at compile time, and should be ideally used.

By defining these dependencies, we can avoid recreating functions/objects when there is no need.

This can then be combined with components that have been used with memo with it's default arePropsEqual. Like before, this is just a referential comparison, but the references themselves have been memoised already in a way that has the dependencies defined.

We have essentially provided this additional metadata.


Memoization can only mask the real issue

You might reach for memoization because you have some performance problems. Because of the cost outlined above, if you don't have a performance problem, you absolutely should not introduce memoization complexities, as that would be counterproductive and achieve nothing for the end user in return. Rerenders are usually cheap and borderline free for most components. You usually don't even need to think about it. A common "red flag" is when someone new to React starts trying to reduce rerenders when they don't actually have a real tangible problem to solve.

If you do have a performance problem, the first thing you should do is look into the root cause of that problem. Do not "mask" it with memoization unless you are confident that the "work" that is taking all the time on rerender can not be optimized to the degree you need. After all, when it does have to rerender, it's still a problem, right?

Try to make the rerender cheap first and foremost. Some common cases include:

  • You have defined a component within a component (possibly masquerading as a function) and now an entire part of the DOM is being removed and readded on every render. This is not normal and needs to be debugged and fixed. Sometimes, newer folks think "these re-renders are so expensive" but have mistaken "re-render" to mean the DOM gets taken out & replaced again in its entirety. Re-renders are supposed to be granular incremental changes automated by React. If you see the whole DOM switching out for what could have been a minor DOM modification, you've got a bug and it's not the concept of a re-render that is the problem, even if it is the "trigger" to the actual bug.
  • Some unoptimized libraries are doing tons of compute work. That probably should be swapped or patched/fixed. Or perhaps this work is your own and unavoidable. Perhaps it could even be pushed into a WebWorker if suitable.
  • User interaction is laggy due to many DOM changes. If it's a long list, look into virtualisation and useTransition. Maybe the problem can be solved by tactically using useMemo and useCallback inside the component or its children.

In short, curated component memoization done well is a maintenance burden, and it should only be undertaken after a cost-benefit analysis in individual and specific circumstances. Basic component memoization might accidentally change the behaviour of impure components and has questionable benefits in isolation, regardless. These decisions are impractical to be made universally at the framework level, and there's trade offs on all the options.


Addendum: React 19 Compile time solution

We outlined earlier that React fundamentally can not automatically know the additional information it needs to reach "meaningful memorization" at runtime. So it has to be provided by a person.

But by using static analysis, compile time tools like the emergent React Compiler can use the code itself to infer what values are derived from which dependant values.

This could then be used to achieve an effect which is conceptually the same as automatically wrapping things in useMemo and auto-configuring the dependency array.

A compiler also has the added benefit of being able to distinguish between userland code that is part of your project and third party code that you do not author or directly control.

That means it could then add component memoization (memo) exclusively to your components and stipulate that those must be pure -- without affecting third-party components, which may be inpure and beyond your control.

This effectively helps reduce the burden of tediously adding memoization hooks to an application. And it also potentially reduces the chance of introducing faulty inaccurate dependency arrays by way of developer error.

However, this is just a helping hand, and not a silver bullet. Your code still needs to be written in a way that allows the compiler to correctly optimize rerenders. And, conceptually, these alterations are themselves "hints" to the compiler about relationships -- albeit less tedious and explicit than hand-crafted dependency arrays.

In some ways, the esoteric code changes needed to coerce the compiler towards the optimal solution are less visible and direct than dependency arrays.

A new problem is likely to emerge where developers see that all components are memo'ed through the React Developer Tools, but actually, it could be a false sense of security. The configuration of that memoization, which the compiler has decided, is crucial. And its not necessarily going to be doing what you think it should.

And any static analysis solution may meet situations whereby the necessary inferences can not be made. They can then either:

  1. Error, and then demand code is written in a more constrained way. This arguably introduces a new burden, but hopefully a lesser one.
  2. Fallback to no memoization or weaker memoization in these cases and (hopefully) warn about it.

So these solutions also do not equate to application-wide, free and perfectly accurate "meaningful memoization" out of the box. They have a different set of tradeoffs.

But one argument is that they can help improve overall general performance at low cost, even if imperfect; and in the process reduce slightly the likelihood devs encounter a problem that requires them to think about all this in the first place.

Sign up to request clarification or add additional context in comments.

3 Comments

Given that React has now released a compiler that will auto-memoize, how do you feel they were able to accomplish this despite the problems you highlighted?
I have just reworked the answer to cover React Compiler. It doesn't change the fundamental problems. But it does allow a new angle to reduce the developer burden. However, it is still itself heavily caveated and the "auto-memoize" function is not equivalent "to universal, free and always-perfect memoization". The latter remains impossible, for the same original reasons in the answer. In short -- they've reduced the burden of memoization configuration. And that is possible only because of the extra inputs (code analysis) afforded to compilers.
Very nice of you to update your answer based on new information, speaks volumes of your character. Thumbs up!
3

First, React.memo is not React.useMemo.

If you are using class component, you can inherit from PureComponent. If you are using function component, there is Anonymous memo node for each function component wrappered by the memo. The deeper the tree is, the lower performance can be.

I still believe the function component is a bad design choice. It makes everything a wrapper. That make the virtual dom tree unnecessarily deeper. If the only tool you have is a hammer, you tend to see every problem as a nail.

If you can master the difference between value comparing and reference comparing then you can use memo almost everywhere. Just make sure to make a shallow copy when you change the object value, which is a good approach for almost everything. The reference comparison virtually cost nothing. Because the memo doesn't do deep comparison, the cost for comparing can be ignored. But if you have children, then the props changes every time. That beats the purpose of memo.

So, considering all these facts, I think you should select these components that don't have children, have props as parameters to generate state and redraw. For example, a chart that accept data from props.

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.