Skip to content

Instantly share code, notes, and snippets.

@slikts
Last active September 12, 2024 18:33
Show Gist options
  • Save slikts/fd3768de1493419ed9506002b452fcdc to your computer and use it in GitHub Desktop.
Save slikts/fd3768de1493419ed9506002b452fcdc to your computer and use it in GitHub Desktop.
Advanced memoization and effects in React

nelabs.dev

Advanced memoization and effects in React

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:
    "a" === "a" // → true
    Compared to objects:
    { a: 1 } === { a: 1 } // → false
    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.
  • 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:
     const a = {};
     a.b = { a };
    Trying to recursively walk through a circular object will just lead to 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.

Reactivity is all about data dependencies

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.

Reference semantics defeat simple memoization

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.

A flat list of primitive values is the ideal case for dependencies in React

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 value-semantic

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.

Sometimes you simply have complex dependencies and nesting

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)));

Further reading

@macmaster
Copy link

I'm not going to beat the JSON.stringify horse to death. The two before me already did a good job of that.
Why not just stick with what Kent C. Dodds did in his use-deep-compare-effect? I dropped lodash's isEqual in as a substitute in my mini rewrite, and it seems to work just fine.

@lxsmnsyc
Copy link

It's not worth using useMemo() if it's a simple object with a fixed shape:

const { a, b } = obj;
React.useEffect(() => {
  const obj = { a, b }; // Either reconstruct the object or use the values directly
}, [a, b]);

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.

I see, thanks for mentioning that.

@otakustay
Copy link

I'd like to introduce a magic hook, I named it useOriginalCopy:

function useOriginalCopy<T>(value: T, equals: CustomEquals<T> = shallowEquals): T {
    const cache = useRef<T | undefined>(undefined);

    if (equals(cache.current, value)) {
        return cache.current as T;
    }

    cache.current = value;
    return value;
}

This hook translates a "content equals" object into a "reference equals" one with a custom equals function, which can help useMemo and useEffect much, this is really the most fancy hook I've ever made.

I published it via @huse/previous-value package so you can use it like:

import {useOriginalCopy} from '@huse/previous-value';

Source code: https://github.com/ecomfe/react-hooks/blob/master/packages/previous-value/src/index.ts

@Sewdn
Copy link

Sewdn commented Feb 27, 2020

@otakustay nice! thanks

@Aryk
Copy link

Aryk commented Mar 8, 2021

I stumbled upon this article because I'm seriously stumped by something in my react native projects:

When I use "memo" on a component and render it, I always get back a different object even if the component itself did not rerender. If I try to use this for example as a prop into FlatList, it will cause FlatList to rerender.

If I take the same component and do:

const ListHeader = useMemo(() => <MemoizedComponent /> ,[])

Then ListHeader doesn't change between renders. I verified that the MemoizedComponent did not have any new props between renders and it's always a "Different" value between renders unless I use useMemo.

Is that correct. What am I missing?

@dangrabcad
Copy link

@Aryk What you're missing is where the memoization takes place. The value of <Component foo={bar} /> isn't the result of rendering the Component, it's actually a funny object, something like {type: "functionComponent", name: "Component", props: {foo: "bar"}}. After your component returns, React looks at the returned object and then calls the Component function to find out what it renders. Like an object literal, this syntax creates a new object every time, so you always get back a different object. The decision whether to re-render the child (possibly altered by memo happens after that). And of course the useMemo gives you the same object every time - that's what it's for. But wrapping the JSX inside the useMemo doesn't change whether the component will re-render or not, because React doesn't care whether the funny object is the same object or different, it'll make the decision about re-rendering regardless.

@Aryk
Copy link

Aryk commented Apr 7, 2022

Thanks @dangrabcad - that's helpful! You wrote:

doesn't change whether the component will re-render or not, because React doesn't care whether the funny object is the same object or different, it'll make the decision about re-rendering regardless.

So if the "funny object" which is just an object literal is being decided by React for rerending...why would it re-render if it's rendering in the same spot with the same key/value pairs?

@dangrabcad
Copy link

@Aryk Because React (by default) doesn't use the key/value pairs to decide whether to re-render or not, it just re-renders everything. That's what React.memo is for: it creates a wrapper class that does use the key/value pairs to decide whether to render the wrapped component.

@oulfr
Copy link

oulfr commented Mar 27, 2024

@otakustay nice solution adapted to loadash:

    import {useEffect, useRef} from 'react';
    import {isEqual} from 'lodash';
    
    const useOriginalCopy = (value) => {
        const cache = useRef(value);
        const equalsRef = useRef(isEqual);
        useEffect(
            () => {
                equalsRef.current = isEqual;
            },
            [isEqual]
        );
        useEffect(
            () => {
                if (!equalsRef.current(cache.current, value)) {
                    cache.current = value;
                }
            },
            [value]
        );
    
        return isEqual(cache.current, value) ? cache.current : value;
    }
    export default useOriginalCopy

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment