React performance techniques

React is pretty fast for most workflows. However, when the time comes to squeeze performance down to the last drop, we may need to adopt some rather unique techniques.

Batch updates

When we change state using this.setState or useState hook calls, this instructs React to re-render and update the component. React will automatically batch updates in the case of React event handlers like onClicks.

However, this automatic batching is not enabled outside of React event handlers and use of timers such as setTimeout and promises.

For example, in the example below, we have two buttons. The first of which is synchronous, and the second wrapped in a Promise.

function App() {
  const [item1, setItem1] = useState(1);
  const [item2, setItem2] = useState(0);
  const renderLogRef = useRef([]);

  renderLogRef.current.push(
    `Item 1 state: ${item1} | Item 2 state: ${ALPHA[item2 % ALPHA.length]}`,
  );

  function handleClick() {
    renderLogRef.current.push(`handleClick`);
    setItem1((i) => i + 1);
    setItem2((i) => i + 1);
  }

  function handleClickWithPromise() {
    renderLogRef.current.push(`handleClickWithPromise`);
    Promise.resolve().then(() => {
      setItem1((i) => i + 1);
      setItem2((i) => i + 1);
    });
  }

  return (
    <div className="App">
      <div>
        {item1} {ALPHA[item2 % ALPHA.length]}
      </div>
      <button onClick={handleClick}>rerender</button>
      <button onClick={handleClickWithPromise}>rerender w/ promise</button>
      <ol>
        {renderLogRef.current.map((log, i) => (
          <li key={i}>{log}</li>
        ))}
      </ol>
    </div>
  );
}

When the rerender button is clicked, it only re-renders once, while the rerender w/ promise button re-renders twice - item 1 updates first, followed by item 2.

We can prevent this regression by using the unstable_batchedUpdates api.

import { unstable_batchedUpdates } from 'ReactDOM';
function handleClickWithPromise() {
  renderLogRef.current.push(`handleClickWithPromise`);
  Promise.resolve().then(() => {
    unstable_batchedUpdates(() => {
      setItem1((i) => i + 1);
      setItem2((i) => i + 1);
    });
  });
}

Although prefixed with unstable, this function has been around for more than 3 years. It has also been mentioned by React core contributors as a solution for repeat re-renders.

Circumvent the reconciliation step

Although React is pretty efficient with updates, it is not the most efficient. React has to compare the previous state and current state to determine if it should update the DOM. For scenarios like animation, almost every state is going to be different, so the reconciliation step (aka diffing) is almost always unnecessary.

There isn't a way to toggle this behavior off in React. So sometimes the only solution is to stop using React.

Let's use a hover animation for example:

function TiltingImage() {
  const containerRef = useRef();
  const handlePointerMove = useCallback((e) => {
    requestAnimationFrame(() => {
      const rect = containerRef.current.getBoundingClientRect();
      const centerX = rect.x + rect.width / 2;
      const centerY = rect.y + rect.height / 2;
      const x = (centerY - e.clientY) / rect.height;
      const y = -((centerX - e.clientX) / rect.width);
      containerRef.current.style.transform = `rotateX(${x}deg) rotateY(${y}deg)`;
    });
  }, []);

  const handlePointerLeave = useCallback(() => {
    requestAnimationFrame(() => {
      containerRef.current.style.transform = `rotateX(0deg) rotateY(0deg)`;
    });
  }, []);

  return (
    <div style={{ perspective: '5rem' }}>
      <div
        ref={containerRef}
        onPointerMove={handlePointerMove}
        onPointerLeave={handlePointerLeave}
        style={{
          display: 'inline-block',
          transition: 'transform 0.5s ease-out',
        }}
      >
        <img
          height="300"
          width="300"
          src="https://source.unsplash.com/random/300×300"
          alt="placeholder"
        />
      </div>
    </div>
  );
}

In the component above, instead of using state to set the style, we are bypassing React's diffing by setting the style directly on the container. This reduces the amount of unnecessary work, hence speeding up our animation.

The big downside of this method is that now you are left to handle the component's state imperatively, instead of the declarative way that React is set up. This means that cleanup and edge cases must be handled carefully, or memory leaks may occur.

Final thoughts

These tricks are useful, but they should be used sparingly. React is plenty fast for most workflows, so these techniques should only be used when there are no better alternatives. Remember that readable code triumphs over performance.