Prevent Unnecessary Re-Renders: A Guide to Using memo, useMemo, and useCallback

Intro

In this second installment of our React optimization series, we delve into an additional technique to boost your application's performance. In our previous article, we explored how a well-architected system that employs props and children can prevent unnecessary re-renders.

However, there are some scenarios where this approach falls short. In such cases, React.memo can prove to be a valuable tool.

React.memo is a powerful solution that can optimize your application's performance by preventing unnecessary re-renders of components. By default, React re-renders a component whenever its state or props change. Even if only a small part of the component changes, the entire component is re-rendered. This can be especially taxing on performance, especially when rendering large or frequently used components.

memo allows you to cache the result of a function and reuse it unless the input (props) changes. Consequently, if the props of a component remain the same, React will utilize the cached result of the function and sidestep the need to re-render the component. This can considerably enhance the performance of your application.

This can be wrapping the component we want to memoize (in this case the Info) with React.memo:

const Form = () => {
  const [value, setValue] = useState('');
  return (
    <>
      <input type='text' onChange={(e) => setValue(e.target.value)} />
      <MemoizedInfo name='fer-codes' />
    </>
  );
};

const MemoizedInfo = React.memo(({ name }) => { 
  console.log('I was rendered') <--- will only be called once!

  return (
    <>
      <p>{name} accepts to pay the ammount in full</p>
    </>
  );
});

In this scenario, React.memo effectively prevents the component from re-rendering when the parent undergoes state updates since the name prop of the component remains constant.

To elaborate, consider a memoized component as a self-regulating entity that determines when to re-render, based on its own criteria. This could range from never re-rendering to re-rendering only when specific props change.

Now, let's explore the outcome if we alter the props' structure by using an object instead of a string:

const Form = () => {
  const [value, setValue] = useState('');

  // update the shape of the props to be an object
  const info = { name: 'fer-codes' };

  return (
    <>
      <input type='text' onChange={(e) => setValue(e.target.value)} />
      <MemoizedInfo info={info} />
    </>
  );
};

const MemoizedInfo = React.memo(({ info }) => { 
  console.log('I was rendered') <--- Will be called on every update from the parent

  return (
    <>
      <p>{info.name} accepts to pay the ammount in full</p>
    </>
  );
});

It may come as a surprise that the Info component undergoes re-rendering every time the input is updated, i.e., every update from the parent component.

One might ask, "Why does this occur if the name value is constant?"

Indeed, while the value remains the same, the reference to the Info object is not constant. This indicates that React considers every update to be associated with a new object. As a result, the memoized Info component's prop changes, and it re-renders.

This phenomenon arises since React utilizes referential equality to compare props.

Remember this to follow this vital rule when using memo:

It is recommended to memoize every prop that is not a primitive data type.

A quick lesson about Primitives vs None-Primitives values in JavaScript

JavaScript has 7 primitive data types, which are data that are not objects and do not have any properties or methods associated with them. Those primitives are: string, number, boolean, undefined, null, symbol and bigint.

When we compare primitive values, we compare them by their value:

const name1 = 'fer-codes'
const name2 = 'fer-codes'

name1 === name2 ✅
true // console output

However, the scenario changes significantly for objects. Unlike primitive data types, objects are reference types, implying that they are stored and transmitted by reference, rather than by value.

When you create an object in JavaScript, a reference to that object is established, instead of generating a duplicate copy of the object.

Since objects are compared by their reference value, this returns false.

const info1 = { name: 'fer-codes' }
const info2 = { name: 'fer-codes' }

info1 === info2 ❌
false // console output (not the sabe object in memory)

This same rule applies to Objects, Arrays or Functions since all of them are considered objects in JavaScript.

Memoizing none-primitives

React's useMemo hook allows you to memoize a function and store its result in cache, ensuring that the function is only re-computed when its dependencies change. The useMemo function receives two arguments: the computation function and an array of dependencies on which the computation relies.

For example:

const Form = () => {
  const [value, setValue] = useState('');

  // memoize the "info" object
  const info = useMemo(() => { name: 'fer-codes' }, []);

  return (
    <>
      <input type='text' onChange={(e) => setValue(e.target.value)} />
      <MemoizedInfo info={info} />
    </>
  );
};

In this case the info object will always be the same since we aren't passing any dependencies to the array.

Now let’s say that we want to pass a function as a prop to the Info component. In that case, it is necessary to memoize functions as well if you want to avoid recreating them every time the component renders.

In such situations, we can utilize the useCallback hook.

const Form = () => {
  const [value, setValue] = useState('');  
  const info = useMemo(() => { name: 'fer-codes' }, []);
  const submit = useCallback(() => { /* your logic here */ }, []);

  return (
    <>
      <input type='text' onChange={(e) => setValue(e.target.value)} />
      <MemoizedInfo info={info} submit={submit} />
    </>
  );
};

The effect of using useCallback is similar to that of useMemo. In this case, the function will not be re-created unless an item in the dependency array useCallback is modified. As we don't have any dependencies in this example, the submit function will be generated just once.

In the end, the optimized code should look like this:

const Form = () => {
  const [value, setValue] = useState('');  
  const info = useMemo(() => { name: 'fer-codes' }, []);
  const submit = useCallback(() => { /* your logic here */ }, []);

  return (
    <>
      <input type='text' onChange={(e) => setValue(e.target.value)} />
      <MemoizedInfo info={info} submit={submit} />
    </>
  );
};

const MemoizedInfo = React.memo(({ info, submit }) => { 
  return (
    <>
      <p>{info.name} accepts to pay the ammount in full</p>
            <button onClick={submit}>Submit</button>
    </>
  );
});

After using React.memo in combination with useMemo and useCallback, we are ensuring that the MemoizedInfo the component will not be re-rendered on state updates from the parent component (Form) that are in this case unrelated to the MemoizedInfo component.

By using React.memo together with useMemo and useCallback, we guarantee that the MemoizedInfo component won't be re-rendered due to state updates from the parent component (Form) that are unrelated to the MemoizedInfo component.

Custom props comparison: arePropsEqual

By default memo will do a shallow comparison between the old props and the new props, In other words, it checks if the new property is reference-equal to the previous one. So, even if the components within the object or array are identical, if a new object or array is created each time the parent is re-rendered, it will still be evaluated as a new property. That is why is important to memoize objects and functions as we saw before, you can read more about shallow copies here.

Suppose you need to compare the old and new props in a way not covered by the default shallow equality comparison behavior. In that case, you can pass a custom comparison function as the second argument to the memo function. This function should only return true if the new props would result in the same output as the old props. Otherwise, it should return false.

const Info = React.memo(({ info, submit }) => { 
  return (...);
});

const arePropsEqual = (prevProps, nextProps) => {
  return nextProps.info.name === prevProps.info.name;
};

const MemoizedInfo = memo(Info, arePropsEqual);

This can be very useful when you want the component to only re-render based on specific prop changes.

One final piece of advice when using a custom comparison function is to avoid conducting deep equality checks. Performing extensive equality checks on data structures can significantly slow down your application if it is modified later, potentially causing it to freeze for several seconds. If you're properly memoizing objects and functions, it's unlikely that you'll need to conduct a deep equality check.

Conclusion

In conclusion, optimizing React applications is a crucial step in ensuring their performance and scalability. React.memo, useMemo, and useCallback are useful tools that can be leveraged to achieve this optimization.

Memoization helps to prevent unnecessary re-renders and it's recommended to memoize every prop that is not a primitive data type.