← main page

Maximizing Component Efficiency in React: Best Practices for Performance

The React framework is a powerful tool, providing developers with the ability to prevent unnecessary changes in the DOM. It does this by rendering the entire components tree to build the virtual DOM and understand if anything has changed. This process allows React to identify which DOM elements need to be updated and which can remain the same.

However, React can do even better than this. It provides developers with the ability to optimize the rendering of the components tree, so that the virtual DOM only needs to be updated when absolutely necessary. This can be achieved by leveraging memoization of components, which can be used to store the rendered trees, ensuring that they are only calculated when absolutely necessary.

By leveraging React's powerful features, developers can ensure that their applications are as efficient as possible, preventing unnecessary updates to the DOM and ensuring that their applications are running as smoothly as possible.

function Parent () {
  const [counter, setCounter] = React.useState(0);
  return (
    <>
      <button
        children={counter}
        onClick={() => setCounter((c) => c + 1)}
      />
      <Child />
    </>
  );
}

function Child() {
  return "Child";
}

In this example we have a single button that changes the component state on click, and one component next to it called Child. The change of the state in the Parent component will trigger DOM change. React will build the complete Parent virtual DOM tree, compare it to the older tree to find what has changed and update the text inside of the button. So why do we have Child here?

Child component is obviously has no need to be called ever again but React will do it on every Parent render. You can easily see it by adding console.log() inside of the function:

function Child() {
  console.log("Rendering Child");
  return "Child";
}

Now you can see that with every click on the button, the Child component is being rendered. Whenever Parent component will be rendering it will cause the rendering of all components used inside of it. And while in this example the Child component won't cause any problem cause it does not perform any heavy operations, in real world applications it can be the large list of data, deeply nested tree of elements, or even the whole application sitting next to the theme toggle button.

There are 2 strategies you can use to prevent unnecessary Child renders.

State Isolation

The state change is only triggering the render of the component where the state is declared and its children. In this example it is the Parent component with Child inside. What we could do to prevent the Child component to render multiple times is to prevent the Parent component from rendering. We can achieve it by moving the counter state one level down isolating its state from the parent:

function Parent () {
  return (
    <>
      <Counter />
      <Child />
    </>
  );
}

function Child() {
  console.log("Rendering Child");
  return "Child";
}

function Counter() {
  const [counter, setCounter] = React.useState(0);
  return (
    <button
      children={counter}
      onClick={() => setCounter((c) => c + 1)}
    />
  );
}

If you would run this code you would see that the Child is only printing "Rendering Child" on initialization but never more even when counter is updated. Well done! But what if we cannot change layout or our child components depending on that counter state?

Component Memoization

Let's update our code first:

function Parent() {
  const [counter, setCounter] = React.useState(0);
  return (
    <>
      <button
        children={counter}
        onClick={() => setCounter((c) => c + 1)}
      />
      <Child enough={counter > 3} />
    </>
  );
}

function Child({ enough }) {
  console.log("Render Child");
  return enough ? "Done!" : "Click the button";
}

Now Child is dependant on the counter state and so we cannot isolate it. Yet, we don't need to render Child every time but only when counter is greater than 3. We can achieve it by saying React to check properties of the component and only render it when they are changed. We can do it by using React.memo():

const Child = React.memo(function Child({ enough }) {
  console.log("Render Child");
  return enough ? "Done!" : "Click the button";
});

This was even easier than isolating state in a separate component and the result is amazing. We can drop this component anywhere and be sure that it won't be updated until its properties are not changed. I only wonder why this is not the default behaviour of React?

Memoization Caveats

There are some caveats you should be aware of when using memoization. First of all, memoization is only working with functional components. If you are using class components you should use React.PureComponent instead. It is working in the same way as React.memo() but for class components.

Another thing to be aware of is that memoization is only working with properties of the component. If you are using useState() or useContext() hooks inside of the component, it will be re-rendered every time the state is changed.

And the last thing to be aware of is that memoization is only working with shallow comparison of properties. If you are passing an object, an array, or a function directly into the property, it will be compared by reference and so the component will be updated every time:

function Parent () {
  const [counter, setCounter] = React.useState(0);

  const api = React.useContext(ApiContext);

  return (
    <>
      <button
        children={counter}
        onClick={() => setCounter((c) => c + 1)}
      />

      <Popover settings={{ position: "bottom" }} />

      <Actions features={['close', 'save']} />

      <Form onSubmit={(data) => api.save(data)} />
    </>
  );
}

This can be easily fixed using React.useMemo() and React.useCallback().

Properties Memoization

React.useMemo() calls the given function, stores its result, and returns it on every render until the dependency list is not changed. When the list is changed, the function will be called again and the new result will replace the old one.

This way we can store non-primitive types inside of the component keeping their reference the same:

function Parent () {
  const [counter, setCounter] = React.useState(0);

  const api = React.useContext(ApiContext);

  const isCounterExceeded = counter > 3;

  const settings = React.useMemo(
    () => ({
      position: isCounterExceeded? "top" : "bottom"
    }),
    [isCounterExceeded]
  );

  const features = React.useMemo(
    () => ['close', 'save'],
    []
  );

  const onSubmit = React.useMemo(
    () => (data) => api.save(data),
    [api]
  );

  return (
    <>
      <button
        children={counter}
        onClick={() => setCounter((c) => c + 1)}
      />

      <Popover settings={settings} />

      <Actions features={features} />

      <Form onSubmit={onSubmit} />
    </>
  );
}

To memoize functions React has a shorthand utility React.useCallback():

const onSubmit = React.useCallback(
  (data) => api.save(data),
  [api]
);

Extracting Properties

Some values does not need to be updated and may not depend on any values inside of the component. In such cases it does make sense to extract them to put outside of the component not to ever force JavaScript to build those values again:

const features = ['close', 'save'];

function Parent () {
  return (
    <>
      <Actions features={features} />
    </>
  );
}

Sometimes, this may also be applied to functions:

import api from './api';

const onSubmit = (data) => api.save(data);

function Parent () {
  return (
    <>
      <Form onSubmit={onSubmit} />
    </>
  );
}

And other times the function is dependant on other properties but it doesn't make sense to refresh the component every time because those dependencies are only matter when the function is called.

Referencing Dependencies

function Parent () {
  const [counter, setCounter] = React.useState(0);

  const api = React.useContext(ApiContext);

  const onSubmit = React.useCallback(
    () => api.save(`Clicked ${counter} time(s)`),
    [api, counter]
  );

  return (
    <>
      <button
        children={counter}
        onClick={() => setCounter((c) => c + 1)}
      />

      <Form onSubmit={onSubmit} />
    </>
  );
}

In the example above the onSubmit() function is dependant on the counter and as so it is restored every time the counter is changing. The update of onSubmit() will cause <Form /> to render even if it is memoized. If you think about this example for another second you may find out that this update is redundant.

The onSubmit() implementation doesn’t really matter until it is called. In other words, the onSubmit() code is only worried about the actual, very last component state. To give it to the callback without updating it every time dependencies are changing we can make use of react references.

We can reference either the whole function or its dependencies. Here is the complete example with referenced dependencies:

function Parent () {
  const [counter, setCounter] = React.useState(0);

  // We create a reference for counter state
  // because we want to use it in the callback
  // that will be passed as a property to child
  // component but we don't want the callback
  // to be changed every time the state does
  const counterRef = React.useRef(counter);
  // Right in the component, on every its render,
  // we update the value of the reference with
  // the latest state value
  counterRef.current = counter;

  const onSubmit = React.useCallback(
    // Use the counter from the reference
    // instead of the state to ensure to have
    // the latest value
    () => api.save(`Clicked ${counterRef.current} time(s)`),
    // Refrence objects references are never
    // changed, but the linter may ask you to
    // list all variables you are using in the
    // callback
    [counterRef]
  );

  return (
    <>
      <button
        children={counter}
        onClick={() => setCounter((c) => c + 1)}
      />

      <Form onSubmit={onSubmit} />
    </>
  );
}

In the above component the onSubmit() reference will never change, so <Form /> will always receive the same function and if the Form component is memoized the Parent will never ask it to render again.

We can simplify the code by creating a custom hook to constantly update the reference:

function useLastRef<T>(value: T) {
    const ref = useRef<T>(value);
    ref.current = value;
    return ref;
}

Or install use-last-ref if you prefer packages. Then the code would look like this:

const [counter, setCounter] = React.useState(0);
const counterRef = useLastRef(counter);

const onSubmit = React.useCallback(
  () => api.save(`Clicked ${counterRef.current} time(s)`),
  [counterRef]
);

Note that in cases when values are only used in callback functions, you may not need state at all. Create a reference and update data directly in it:

function Parent() {
  const counterRef = useRef(0);
  
  const onSubmit = React.useCallback(
    () => api.save(`Saved ${++counterRef.current} time(s)`),
    [counterRef]
  );

  return <Form onSubmit={onSubmit} />;
}

Referencing Callback

In many cases it would make more sense to reference the whole callback instead of wrapping each dependency value in references. To accomplish it you save your function to the ref object on each render and create memoized callback that will call that function from the reference:

function Parent({ customer }) {
  const onSubmitRef = useLastRef((newRecord) => {
    api.createRecord(customer.id, newRecord);
  });

  const handleSubmit = React.useCallback(
    (data) => onSaveRef.current(data),
    [onSaveRef]
  );

  return <RecordForm onSubmit={handleSubmit} />;
}

Referencing function may reduce the amount of code a lot and it has no disadvantages compare to referencing dependencies. You can also shorten the code by using use-ref-callback:

import useRefCallback from 'use-ref-callback';

function Parent({ customer }) {
  // The handleSubmit is a never changing function
  // yet always updating the reference to the passed function
  // and accessing the latest data of the component scope
  const handleSubmit = useRefCallback((newRecord) => {
    api.createRecord(customer.id, newRecord);
  });

  return <RecordForm onSubmit={handleSubmit} />;
}

This technique is very handful when passed callbacks may be not memoized. You simply wrap each of them and pass further:

function Parent({ onOne, onAnother }) {
  const handleOne = useRefCallback(onOne);
  const handleAnother = useRefCallback(onAnother);

  return <MemoizedChild onOne={handleOne} onAnother={handleAnother} />;
}

Conclusions

React doesn’t do its best on optimization of calculations, probably due to the backward compatibility issue. But it does provide us will all the necessary tools. By using React.memo() in pair with React.useMemo(), React.useCallback(), React.useRef() hooks, and a couple of techniques you can significantly reduce the amount of calculations that does happen when component are rendering. And with its enormous benefit it is still an easy change in your code. So let’s do it!