Victor is a full stack software engineer who loves travelling and building things. Most recently created Ewolo, a cross-platform workout logger.
How to stop pure functional components from rendering in React.js

Recently I had to deal with a very interesting bug that involved a component getting rendered multiple times causing performance problems. In this article we will look at how to use `memo` to stop functional components from rendering multiple times.

TLDR; When passing objects and functions as props to functional components in React.js, it might be necessary to use memo and pass a custom function that checks for props equality. As added performance, use useCallback on the parent to only recompute functions which will be used as props when their specific dependencies change.

The problem

The essential problem at hand is that when passing a bunch of properties to a functional component in React, the default behaviour is to not check for any prop changes and simply render the component. The DOM is updated conditionally depending on changes but the simple act of rendering itself can cause performance issues for certain heavy components.

To test out the problem let us take the age old example of a simple counter, i.e. a component that takes a number and on a button click adds 1 to that number and displays it.

function App() {
  const [numCounter, setNumCounter] = useState(0);
  
  const onCounterAddClick = useCallback(() => {
    setNumCounter(numCounter + 1);
  }, [numCounter]);

  return (
    <div>
      <Counter num={numCounter} onCounterAddClick={onCounterAddClick} />
    </div>
  );
}

function Counter({ num = 0, onCounterAddClick = () => {} }) {  
  // count the number of times this component is rendered.
  console.count("Counter");
  return <CounterContents num={num} onClick={onCounterAddClick} title="Counter" />;
}

function CounterContents({ num = 0, title = "", onClick = () => {} }) {
  return (
    <div style={{ margin: "5px" }}>
      <h4>{title}</h4>
      <div>
        {num}
        <button style={{ marginLeft: "5px" }} onClick={onClick}>
          Add
        </button>
      </div>{" "}
    </div>
  );
}

There are a few points to note here:

  • This is a pretty basic Counter component that has its properties being passed from the parent (App).
  • We have prematurely optimized onCounterAddClick to only get recomputed if numCounter changes. This was not necessary for the above example but it is illustrative to explain how useCallback will not prevent a re-render.
  • The Counter component has a console.count("Counter") right before the return statement which is a pretty neat feature that lets the console count how many times this particular statement has been issued.
  • We have refactored the contents of Counter into CounterContents as we will be reusing this for other counters that we make.

Running the above code will result in the number being updated on every click. Moreover, the component is rendered once per click which is very very exciting!

Now on to bigger and better things. Let us add another counter to this page which does pretty much the same thing but comes with it's own number, let us call this CounterMemo as a sign of things to come.

function App() {
  const [numCounter, setNumCounter] = useState(0);
  const [numCounterMemo, setNumCounterMemo] = useState(0);

  const onCounterAddClick = useCallback(() => {
    setNumCounter(numCounter + 1);
  }, [numCounter]);

  const onCounterMemoAddClick = useCallback(() => {
    setNumCounterMemo(numCounterMemo + 1);
  }, [numCounterMemo]);

  return (
    <div>
      <Counter num={numCounter} onCounterAddClick={onCounterAddClick} />
      <CounterMemo num={numCounterMemo} onCounterMemoAddClick={onCounterMemoAddClick} />
    </div>
  );
}

function Counter({ num = 0, onCounterAddClick = () => {} }) {
  // count the number of times this component is rendered.
  console.count("Counter");
  return <CounterContents num={num} onClick={onCounterAddClick} title="Counter" />;
}

function CounterMemo({ num = 0, onCounterMemoAddClick = () => {} }) {
  // count the number of times this component is rendered.
  console.count("CounterMemo");
  return <CounterContents num={num} onClick={onCounterMemoAddClick} title="CounterMemo" />;
}

function CounterContents({ num = 0, title = "", onClick = () => {} }) {
  return (
    <div style={{ margin: "5px" }}>
      <h4>{title}</h4>
      <div>
        {num}
        <button style={{ marginLeft: "5px" }} onClick={onClick}>
          Add
        </button>
      </div>{" "}
    </div>
  );
}

CounterMemo comes with its own state and logging but is otherwise exactly the same as Counter. As usual, we've tried to be clever and pre-optimize onCounterMemoAddClick so that it is computed only if numCounterMemo changes.

Hitting the click button on either of the counter components results in having both the components rendered. The actual counter numbers are correct in html but the console spits out counts for both Counter and CounterMemo components.

The solution

React.js v17 comes with a memo function that allows us to explicitly skip rendering of a component based on whether the props have changed or not:

const CounterMemo = memo(
  ({ num = 0, onCounterMemoAddClick = () => {} }) => {
    console.count("CounterMemo");
    return <CounterContents num={num} onClick={onCounterMemoAddClick} title="CounterMemo" />;
  },
  (prevProps, nextProps) => {
    if (prevProps.num !== nextProps.num) {
      return false;
    }
    return true; // props are equal
  }
);

  • The second parameter to memo is a "arePropsEqual" function that takes the previous props and the next props as parameters. Returning true means props are equal no render is required and false means something has changed.
  • Here we know that we have memoized the onCounterMemoAddClick function in the parent so we can safely ignore checking whether it has changed or not.

Hitting the click button on our newly memoized component results in both counters being rendered. However, clicking on the regular counter now will only render the regular counter! You can find the sample code for this experiment here. Happy memoizing!