paint-brush
Memoization in React: Powerful Tool or Hidden Pitfall?by@socialdiscoverygroup
1,060 reads
1,060 reads

Memoization in React: Powerful Tool or Hidden Pitfall?

by Social Discovery GroupJuly 1st, 2024
Read on Terminal Reader
Read this story w/o Javascript

Too Long; Didn't Read

A widespread approach in React application development is to cover everything with memorization. The Social Discovery Group team discovered how overusing memoization in React apps can lead to performance issues. Learn where it fails and how to avoid these hidden traps in your development.
featured image - Memoization in React: Powerful Tool or Hidden Pitfall?
Social Discovery Group HackerNoon profile picture


A widespread approach in React application development is to cover everything with memoization. Many developers apply this optimization technique liberally, wrapping components in memoization to prevent unnecessary re-renders. On the surface, it seems like a foolproof strategy to enhance performance. However, the Social Discovery Group team has discovered that when misapplied, memoization can actually break in unexpected ways, leading to performance issues. In this article, we'll explore the surprising places where memoization can falter and how to avoid these hidden traps in your React applications.


What is Memoization?

Memoization is an optimization technique in programming that involves saving the results of expensive operations and reusing them when the same inputs are encountered again. The essence of memoization is to avoid redundant computations for the same input data. This description is indeed true for traditional memoization. You can see in the code example that all computations are cached.


const cache = { }
function calculate (a) {
    if (Object.hasOwn(cache, a)) {
        return cache[a]
    }

    cache[a] = a * a
    return cache[a]
}


Memoization offers several benefits, including performance improvement, resource savings, and result caching. However, React's memoization works until new props come in, meaning only the result of the last call is saved.


const prev = {
    value: null,
    result: null
}

function calculate(a) {
    if (prev.value === a) {
        return prev.result
    }

	prev.value = a
    prev.result = a * a
    return prev.result
}


Memoization Tools in React

The React library provides us with several tools for memoization. These are the HOC React.memo, the hooks useCallback, useMemo, and useEvent, as well as React.PureComponent and the lifecycle method of class components, shouldComponentUpdate. Let's examine the first three memoization tools and explore their purposes and usage in React.


React.memo

This Higher-Order Component (HOC) accepts a component as its first argument and an optional comparison function as its second. The comparison function allows for manual comparison of previous and current props. When no comparison function is provided, React defaults to shallow equality. It's crucial to understand that shallow equality only performs a surface-level comparison. Consequently, if the props contain reference types with non-constant references, React will trigger a re-render of the component.


const Button = memo((props) => {
    return (
       <button onClick={props.onClick}>
           {props.title}
       </button>
     )
}, 
(prevProps, props) => { 
    return props.title === prevProps.title 
})


React.useCallback

The useCallback hook allows us to preserve the reference to a function passed during the initial render. On subsequent renders, React will compare the values in the hook's dependency array, and if none of the dependencies have changed, it will return the same cached function reference as last time. In other words, useCallback caches the reference to the function between renders until its dependencies change.


const callback = useCallback(() => {
    // do something
}, [a, b, c])


React.useMemo

The useMemo hook allows you to cache the result of a computation between renders. Typically, useMemo is used to cache expensive computations, as well as to store a reference to an object when passing it to other components wrapped in the memo HOC or as a dependency in hooks.


const value = useMemo(() => {
    return [1, 2, 3, 4, 5].filter(it => it % 2 === 0)
}, [])


Full memoization of a project

In React development teams, a widespread practice is comprehensive memoization. This approach typically involves:

  • Wrapping all components in React.memo
  • Using useCallback for all functions passed to other components
  • Caching computations and reference types with useMemo


However, developers don't always grasp the intricacies of this strategy and how easily memoization can be compromised. It's not uncommon for components wrapped in the memo HOC to unexpectedly re-render. At Social Discovery Group, we've heard colleagues claim, "Memoizing everything isn't much more expensive than not memoizing at all."


We've noticed that not everyone fully grasps a crucial aspect of memoization: it works optimally without additional tweaks only when primitives are passed to the memoized component.


  1. In such cases, the component will re-render only if the prop values have actually changed. Primitives in props are good.

  2. The second point is when we pass reference types in props. It is important to remember and understand that there is no magic in React – it is a JavaScript library that operates according to the rules of JavaScript. Reference types in props (functions, objects, arrays) are dangerous.


    For example:


const a = { c: 1 }
const b = { c: 1 }
a === b // false

First call: MemoComponent(a)
Second call: MemoComponent(b)

const MemoComponent = memo(({object}) => {
    return <div />
}, (prevProps, props) => (prevProps.object === props.object)) // false


If you create an object, another object with the same properties and values is not equal to the first one because they have different references.


If we pass what seems to be the same object on a subsequent call of the component, but it is actually a different one (since its reference is different), the shallow comparison that React uses will recognize these objects as different. This will trigger a re-render of the component wrapped in memo, thus breaking the memoization of that component.


To ensure safe operation with memoized components, it's important to use a combination of memo, useCallback, and useMemo. This way, all reference types will have constant references.

Teamwork: memo, useCallback, useMemo

Let’s break the memo, shall we?

Everything described above sounds logical and simple, but let's take a look together at the most common mistakes that can be made when working with this approach. Some of them might be subtle, and some might be a bit of a stretch, but we need to be aware of them and, most importantly, understand them to ensure that the logic we implement with full memoization doesn't break.


Inlining to memoization

Let's start with a classic mistake, where on each subsequent render of the Parent component, the memoized component MemoComponent will constantly re-render because the reference to the object passed in params will always be new.


const Parent = () => {
    return (
        <MemoComponent params={[1, 2 ,3]} />
    )
}


To solve this problem, it's sufficient to use the previously mentioned useMemo hook. Now, the reference to our object will always be constant.


const Parent = () => {
  const params = useMemo(() => {
        return [1, 2 ,3]
	  ), [])

    return (
        <MemoComponent params={params} />
    )
}


Alternatively, you can move this into a constant outside the component if the array always contains static data.


const params = [1, 2 ,3]

const Parent = () => {
    return (
        <MemoComponent params={params} />
    )
}


A similar situation in this example is passing a function without memoization. In this case, like in the previous example, memoization of the MemoComponent will be broken by passing a function to it that will have a new reference on each render of the Parent component. Consequently, MemoComponent will render anew as if it were not memorized.


const Parent = () => {
    return (
        <MemoComponent onClick={() => {}} />
    )
}


Here, we can use the useCallback hook to preserve the reference to the passed function between renders of the Parent component.


const Parent = () => {
    const handleClick = useCallback(() =>
        console.log(‘click’)
    }, [])

    return (
        <MemoComponent onClick={handleClick} />
    )
}


Note taken.

Also, in useCallback, there is nothing preventing you from passing a function that returns another function. However, it's important to remember that in this approach, the function `someFunction` will be called on every render. It's crucial to avoid complex calculations inside `someFunction`.


function someFunction() {
    // expensive calculations (?)
    ...
    return () => {}
}

..............................
const Parent = () => {
    const cachedFunction = useCallback(someFunction(), [])

    return (
        <MemoComponent onClick={cachedFunction} />
    )
}


Props spreading

The next common situation is props spreading. Imagine you have a chain of components. How often do you consider how far the data prop passed from InitialComponent can travel, potentially being unnecessary for some components in this chain? In this example, this prop will break memoization in the ChildMemo component because, on each render of the InitialComponent, its value will always change. In a real project, where the chain of memoized components can be long, all memoization will be broken because unnecessary props with constantly changing values are passed to them:


const Child = () => {}
const ChildMemo = React.memo(Child)

const Component = (props) => {
    return <ChildMemo {...props} />
}

const InitialComponent = (props) => {
	  // The only component that has state and can trigger a re-render
    return (
        <Component {...props} data={Math.random()} />
    )
}


To safeguard yourself, ensure that only the necessary values are passed to the memoized component. Instead of that:


const Component = (props) => { 
    return <ChildMemo {...props} />
}


Use (pass only the necessary props):


const Component = (props) => {
    return (
        <ChildMemo 
            firstProp={prop.firstProp} 
            secondProp={props.secondProp}
        />
    )
)


Memo and children

Let's consider the following example. A familiar situation is when we write a component that accepts JSX as children.


const ChildMemo = React.memo(Child)

const Component = () => {
    return (
        <ChildMemo>
           <div>Text</div>
        </ChildMemo>
    )
}


At first glance, it seems harmless, but in reality, it's not. Let's take a closer look at the code where we pass JSX as children to a memoized component. This syntax is nothing but syntactic sugar for passing this `div` as a prop named `children`.


Children are no different from any other prop we pass to a component. In our case, we are passing JSX, and JSX, in turn, is syntactic sugar for the `createElement` method, so essentially, we are passing a regular object with the type `div`. And here, the usual rule for a memoized component applies: if a non-memoized object is passed in the props, the component will be re-rendered when its parent is rendered, as each time the reference to this object will be new.



The solution to such a problem was discussed a few abstracts earlier, in the block regarding the passing of a non-memoized object. So here, the content being passed can be memoized using useMemo, and passing it as children to ChildMemo will not break the memoization of this component.


const Component = () => {
    const childrenContent = useMemo(
        () => <div>Text</div>,
        [],
    )

    return (
        <ChildMemo>
        	{childrenContent}
        </ChildMemo>
    )
}


ParentMemo and ChildMemo

Let's consider a more interesting example.


const ParentMemo = React.memo(Parent)
const ChildMemo = React.memo(Child)

const App = () => {
    return (
        <ParentMemo>
        	<ChildMemo />
        </ParentMemo>
    )
}


At first glance, it seems harmless: we have two components, both of which are memorized. However, in this example, ParentMemo will behave as if it's not wrapped in memo because its children, ChildMemo, are not memorized. The result of the ChildMemo component will be JSX, and JSX is just syntactic sugar for React.createElement, which returns an object. So, after the React.createElement method executes, ParentMemo and ChildMemo will become regular JavaScript objects, and these objects are not memoized in any way.



As a result, we pass a non-memoized object to the props, thereby breaking the memoization of the Parent component.


const ParentMemo = React.memo(Parent)
const ChildMemo = React.memo(Child)

const App = () => {
    return (
        <ParentMemo 
            children={<ChildMemo />} 
         />
    )
}


To address this issue, it's sufficient to memoize the passed child, ensuring its reference remains constant during the render of the parent App component.


const App = () => {
    const child = useMemo(() => {
        return <ChildMemo />
    }, []);

    return (
        <ParentMemo>
            {child}
        </ParentMemo>
    )
}


Non-primitives from custom hooks

Another dangerous and implicit area is custom hooks. Custom hooks help us extract logic from our components, making the code more readable and hiding complex logic. However, they also hide from us whether their data and functions have constant references. In my example, the implementation of the submit function is hidden in the custom hook useForm, and on each render of the Parent component, the hooks will be re-executed.


const Parent = () => {
	const { submit } = useForm()

	return <ComponentMemo onChange={submit} />
};


Can we understand from the code whether it's safe to pass the submit method as a prop to the memoized component ComponentMemo? Of course not. And in the worst case, the implementation of the custom hook might look like this:


const Parent = () => {
	const { submit } = useForm()

	return <ComponentMemo onChange={submit} />
};


const useForm = () => {
	const submit = () => {}

return { submit }
}


By passing the submit method into the memoized component, we will break the memoization because the reference to the submit method will be new with each render of the Parent component. To solve this problem, you can use the useCallback hook. But the main point I wanted to emphasize is that you shouldn't blindly use data from custom hooks to pass them into memoized components if you don't want to break the memoization you've implemented.


const Parent = () => {
	const { submit } = useForm()

	return <ComponentMemo onChange={submit} />
};


const useForm = () => {
	const submit = useCallback(() => {}, [])

return { submit }
}


When is memoization excessive, even if you cover everything with memoization?

Like any approach, full memoization should be used thoughtfully, and one should strive to avoid blatantly excessive memoization. Let's consider the following example:


export function App() {
  const [state, setState] = useState('')

  const handleChange = (e) => {
    setState(e.target.value)
  }

	return (
    <Form>
       <Input value={state} onChange={handleChange}/>
    </Form>
  )
}

export const Input = memo((props) => (<input {...props} />))


In this example, it's tempting to wrap the handleChange method in useCallback because passing handleChange in its current form will break the memoization of the Input component since the reference to handleChange will always be new. However, when the state changes, the Input component will be re-rendered anyway because a new value will be passed to it in the value prop. So, the fact that we didn't wrap handleChange in useCallback won't prevent the Input component from constantly re-rendering. In this case, using useCallback would be excessive. Next, I would like to provide a few examples of real code seen during code reviews.


const activeQuestionNumber = useMemo(() => {
	return activeQuestionIndex + 1
}, [activeQuestionIndex])


const userAnswerImage = useMemo(() => {
	return `/i/call/quiz/${quizQuestionAnswer.userAnswer}.png`
}, [quizQuestionAnswer.userAnswer])


Considering how simple the operation of adding two numbers or concatenating strings is, and that we get primitives as output in these examples, it's obvious that using useMemo here makes no sense. Similar examples here.


const cta = useMemo(() => {
    return activeOverlayName === 'photos' ? 'gallery' : 'profile'
}, [activeOverlayName])


const attendeeId = useMemo(() => {
    return userId === senderId
        ? recipientId
        : senderId
}, [userId, recipientId, senderId])


Based on the dependencies in useMemo, different results can be obtained, but again, they are primitives and there are no complex calculations inside. Executing any of these conditions on each render of the component is cheaper than using useMemo.


Conclusions

  1. Optimization - not always beneficial.

    Performance optimizations are not free, and the cost of these optimizations may not always be commensurate with the benefits you will gain from them.


  2. Measure the results of optimizations.

    If you don't measure, you can't know whether your optimizations have improved anything or not. And most importantly, without measurements, you won't know if they made things worse.


  3. Measure the effectiveness of memorization.

    Whether to use full memoization or not can only be understood by measuring how it performs in your specific case. Memoization is not free when caching or memoizing computations, and this can affect how quickly your application starts up for the first time and how quickly users can start using it. For example, if you want to memoize some complex calculation whose result needs to be sent to the server when a button is pressed, should you memoize it when your application starts up? Maybe not, because there's a chance the user will never press that button, and executing that complex calculation might be unnecessary altogether.


  4. Think first, then memorize.

    Memoizing props passed to a component only makes sense if it is wrapped in `memo`, or if the received props are used in the dependencies of hooks, and also if these props are passed to other memoized components.


  5. Remember the basic principles of JavaScript.

    While working with React or any other library and framework, it's important not to forget that all of this is implemented in JavaScript and operates according to the rules of this language.


What tools can be used for measurement?

We can recommend at least 4 of these tools for measuring the performance of your application code.


React.Profiler

With React.Profiler, you can wrap either the specific component you need or the entire application to obtain information about the time of the initial and subsequent renders. You can also understand in which exact phase the metric was taken.


React Developer Tools

React Developer Tools is a browser extension that allows you to inspect the component hierarchy, track changes in states and props, and analyze the performance of the application.


React Render Tracker

Another interesting tool is React Render Tracker, which helps detect potentially unnecessary re-renders when props in non-memoized components do not change or change to similar ones.


Storybook with the storybook-addon-performance addon.

Also, in Storybook, you can install an interesting plugin called storybook-addon-performance from Atlassian. With this plugin, you can run tests to obtain information about the speed of initial render, re-render, and server-side rendering. These tests can be run for multiple copies as well as multiple runs simultaneously, minimizing testing inaccuracies.


Written by Sergey Levkovich, Senior Software Engineer at Social Discovery Group