Memoization is a somewhat fraught topic in the React world, meaning that it's easy to go wrong with it, for example, by making memo()
do nothing by passing in children to a component. The general advice is to avoid memoization until the profiler tells you to optimize, but not all use cases are general, and even in the general use case you can find tricky nuances.
Discussing this topic requires some groundwork about the technical terms, and I'm placing these in once place so that it's easy to skim and skip over:
- Memoization means caching the output based on the input; in the case of functions, it means caching the return value based on the arguments.
- Values and references are unfortunately overloaded terms that can refer to the low-level implementation details of assignments in a language like C++, for example, or to memory allocation (i.e., value and reference types in C#), but that should just be ignored in JavaScript, because "pass by reference" doesn't exist in JS; everything is "pass by value", meaning that you can think of passing as copying or cloning, and references are just a kind of value that can refer to mutable objects.
- Value semantics is a term popularized by Rich Hickey that talks about how values behave compared to references at a high, user level of representation, and a simple way to understand value semantics is to think of how primitive values behave in JavaScript:
Compared to objects:
"a" === "a" // → true
The difference in a nutshell is that value-semantic things stay the same (are immutable) and are easy to compare, while referential semantics require jumping through extra hoops to do the same.{ a: 1 } === { a: 1 } // → false
- Value equality and referential equality are part of what value semantics simplify; you can still check for value equality for mutable objects, but that requires using something like lodash's
isEqual()
that recursively walks through the object's properties, and it needs to be repeated every time the object might have changed. - Deep vs shallow equality refers to whether the values are compared recursively or not: for example,
React.memo()
by default just compares the top level props. Deep equality is made particularly complex by the fact that objects can hold circular references:Trying to recursively walk through a circular object will just lead to aconst a = {}; a.b = { a };
RangeError
. - Lazy vs strict refers to when something is executed; you can make code lazy just by wrapping it in a function:
// Strictly executed alert('Hi'); // Same code, but allows lazy execution const sayHi = () => { alert('Hi') };
- Data dependencies vs false dependencies are related concepts to essential and accidental complexity: a general goal of well-factored programs is to minimize dependencies to only data dependencies to limit the complexity of the program. Similarly, value semantics allow focusing on the essential complexity over language implementation details.
- Effect vs side-effect in the functional programming sense refers to function returns vs everything else that the functions might do or depend on. A pure function depends only on its input parameters, and the only thing that it does is returns a value.
The main value proposition of React at a very high level is that it's a declarative and compositional pattern of modeling dependencies and propagating data to them: components declare dependencies on props, which can be other components, and components also depend on effects, which can have their own dependencies, and the React lifecycle uses the declared dependencies to do what otherwise would be dredge work for the programmer: triggering updates, resource cleanups, etc.
In a pedantic functional programming sense useEffect()
would need to be called useSideEffect()
, but React elevates it to something that could be called an "effect system", so an allowance can be made to just call them effects. After all, the point of purity is also to simplify dependency modeling, and that goal can be achieved in different ways. React with hooks in particular is interesting in that way, since it straddles different programming paradigms.
Something that can help building an intuition about reactivity is the fact that spreadsheets are reactive programs: when you populate a cell with a formula like =A1
, you've subscribed to the data in the cell A1
. This is why observable libraries like RxJS, or Redux, or a lot of other tools are centered around the concept of subscriptions: a subscription declares a dependency on changing data, and the reactive pattern at its core is about making data propagate to these subscriptions automatically.
Most users of React don't have a reason to care about the abstract theoretical aspects of it and can take advantage of the simplification it offers anyway, but abstractions become important in advanced contexts, particularly because abstractions enable generalization: for example, like thinking about both components and effects in the abstract category of dependency modeling.
In a reference-semantic world, it's expensive to know when something has changed; that's why, for example, it's not an oversight that React.memo()
or PureComponent
only do shallow equality comparison by default. It's a common misunderstanding that unchanged props would skip re-rendering: it just leaves it to React's actual last line of defense against the performance bottleneck of DOM, which is the VDOM and reconciliation. If a parent gets re-rendered, all the children get re-rendered as well, and then the VDOM gets checked for changes. This is often not a problem, but it still means that a naively implemented React app will be full of cascading rendering, which can become noticeable.
React's official API includes the tools that are supposed to guide the developer to a "pit of success", and basically all of them deal with dependencies: useMemo()
, useCallback()
and useEffect()
have a second parameter for dependencies, and the others declare dependencies on a context or other state.
If your component just takes primitive values as props, just wrapping it in memo()
will work as expected to prevent an unwanted re-render. The same applies to useMemo()
, useCallback()
and useEffect()
: just pass a flat list of primitives as their dependencies and it Just Works™. However, there would be no point to this long article if that's where it ended: sometimes apps can have complex data dependencies, particularly on nested structures, and the go-to example in React is nested child components:
const Foo = memo(({ children }) => (<div>{children}</div>));
It wasn't obvious to me until recently, but the memo()
in this example does nothing unless the children
prop is memoized before passing it in, or if it's a primitive value, because React.createElement()
creates a new object each time. A straightforward solution would be to memoize the children before passing them in, but this puts the onus on the consumer of the component, and that might not be appropriate for something like a library, where you need to plan for users making mistakes.
React offers a workaround in that memo()
has a second parameter for a custom comparator, and there's libraries like react-fast-compare
that make it simple to use it and do deep equality comparisons on props when you need it, but the ways this and other solutions break down is what I meant when I called this topic fraught.
One basic issue that was already mentioned is that deep equality comparisons can't deal with circular references well: circular structures need to be manually flagged and skipped. The next issue is that there are other kinds of dependencies than component props; suppose you've prevented re-renders using react-fast-compare
like so:
import isEqual from "react-fast-compare";
const Foo = memo(({ bar, children }) => {
useEffect(() => { doSomething(bar); }, [bar]);
return children;
}, isEqual);
This now works without unnecessary re-renders:
<Foo bar={{ a: 1 }}><p>Hello</p></Foo>
Meanwhile, when it does re-render because the children
prop has changed, the effect will re-run even though the bar
parameter didn't change its value, because it'll be a reference to a new object. There's a different library for deeply memoizing effects: use-deep-compare-effect
. Now you have two libraries, each using a different way to check for value equality, and doing it repeatedly, and this also still leaves the question open about what to do when you're using the other hooks that can be memoized with dependencies like useCallback()
or useMemo()
. It should cause a justifiable unease about having taken a wrong turn somewhere.
JSON is textual format for serializing plain objects and primitive values, and strings in JavaScript are primitives, so JSON.stringify()
works beautifully for allowing simple value equality checking. The previous example could be modified like so:
const barValue = JSON.stringify(bar);
useEffect(() => { doSomething(bar); }, [barValue]);
It's a native solution and is probably enough for many use cases, but has a significant flaw: ESLint has no idea about the relation between barValue
and bar
, so the react-hooks/exhaustive-deps
rule will make it complain, and eslint --fix
will also automatically add bar
back to the list of dependencies, possibly causing a bug. It could be worked around like so:
// eslint-disable-next-line react-hooks/exhaustive-deps
But linting is a best practice for a reason, and this would disable linting for all values, not just the one that we "know" is a string. A solution could be to pass the dependency as JSON.stringify(bar)
, but ESLint isn't smart enough to understand that either. An another solution would be to unserialize barValue
inside the callback with JSON.parse()
, but all of this is already borderline code smell, and each workaround pushes it closer to, for example, not passing code review.
There's a frustrating answer to most tech questions that "it depends", meaning that in the end it's a judgement call. There might also be a question about why don't the smart people who develop React and JavaScript just fix this, and the answer there as well is that it's a process. For example, there's an active proposal to add value-semantic types to JavaScript that cites React as one of the motivating use cases.
As a glimpse of the bleeding edge, I've made a proof of concept library that deeply memoizes React components by default, by wrapping React.createElement()
in a custom function. It works in a somewhat neat way by turning the shapes of objects into paths on a directed acyclic graph, but a significant caveat is that it has to keep the first object with a certain shape in memory, so it's leaky, and it can't automatically deal with circularity either.
In short, more or less the best one can do is simple workarounds like this:
const useValueMemo = (callback, deps) =>
useMemo(callback, deps.map(dep =>
isPrimitive(dep) ? dep : JSON.stringify(dep)));
It's not worth using
useMemo()
if it's a simple object with a fixed shape:The real issue is with dynamic shapes and nesting.
I've replied to the point about
JSON.stringify()
elsewhere. It's a worthwhile caveat that it preserves property order, but the ordering should be predictable.