Pixelry

useRef for Performance

The only way to improve performance is to do less work. Immutable state changes can cause a lot of work by rerendering entire component trees and updating DOM element. Now imagine that happing at 60fps in a mouse move event. A lot of work means low performance. Time to consider mutable.

useRef and useCallback

Callbacks are a prime place to switch to mutable. This is particularly true if a state is tracked across multiple callbacks or if state updates at a faster frequency than needed to render.

Example

In this example a mouse drag in the component will move a child square.

The square position needs to be stored in state because it is used in the component's render, but since the mouse move event handler needs the current position to calculate the distance, the mouse handler gets updated on every mouse move. This is an addition DOM change. It also tracks the last mouse position which is only used in callbacks, but it also causes component renders and changes to the handlers because it is a useState.

type Point = {
  x: number;
  y: number;
};

function DragRedSquare() {
  // the position of the red square
  const [position, setPosition] = useState<Point>({
    x: 0,
    y: 0,
  });

  // the last mouse position
  const [mouseLast, setMouseLast] = useState<Point>({
    x: 0,
    y: 0,
  });

  // mouse down
  const handleMouseDown = useCallback(
    (event: React.MouseEvent<HTMLDivElement>) => {
      setMouseLast({
        x: event.clientX,
        y: event.clientY,
      });
    },
    [setMouseLast],
  );

  // mouse move
  const handleMouseMove = useCallback(
    (event: React.MouseEvent<HTMLDivElement>) => {
      if (event.buttons == 1) {
        // calculate the distance of the mouse move
        const dx = event.clientX - mouseLast.x;
        const dy = event.clientY - mouseLast.y;

        // move the red square the same distance
        setPosition({
          x: position.x + dx,
          y: position.y + dy,
        });

        // save the mouse position for the next move
        setMouseLast({
          x: event.clientX,
          y: event.clientY,
        });
      }
    },
    [mouseLast, setMouseLast, position, setPosition],
  );

  return (
    <div
      className={clsx(
        'min-h-40', // size
        'relative', // layout
        'overflow-hidden', // overflow
        'border', // border
      )}
      onMouseDown={handleMouseDown}
      onMouseMove={handleMouseMove}
    >
      <div
        className={clsx(
          'w-8 h-8', // size
          'absolute', // layout
          'bg-red-500', // color
        )}
        style={{
          left: position.x,
          top: position.y,
        }}
      ></div>
    </div>
  );
}

Optimized Example

This next example accomplishes the same outcome but without any handlers changing.

The mouse position, which is only used in callbacks, is stored only in a ref. The square position is stored in both a ref and a useState. The handler only reads from the ref and writes to the state. This removes an unnecessary DOM change.

function DragGreenSquare() {
  // the position of the green square
  const [position, setPosition] = useState<Point>({
    x: 0,
    y: 0,
  });

  // the last mouse position
  // this value is a ref and only used in callbacks
  const mouseLast = useRef<Point>({
    x: 0,
    y: 0,
  });

  // the last position of the green square
  // this value is a ref and only used in callbacks
  const positionLast = useRef<Point>(position);

  // mouse down
  const handleMouseDown = useCallback(
    (event: React.MouseEvent<HTMLDivElement>) => {
      mouseLast.current = {
        x: event.clientX,
        y: event.clientY,
      };
    },
    [mouseLast],
  );

  // mouse move
  const handleMouseMove = useCallback(
    (event: React.MouseEvent<HTMLDivElement>) => {
      if (event.buttons) {
        // calculate the distance of the mouse move
        const dx = event.clientX - mouseLast.current.x;
        const dy = event.clientY - mouseLast.current.y;

        // save the mouse position for the next move
        mouseLast.current = {
          x: event.clientX,
          y: event.clientY,
        };

        // save the new green square position
        positionLast.current = {
          x: positionLast.current.x + dx,
          y: positionLast.current.y + dy,
        };

        // move the green square
        setPosition(positionLast.current);
      }
    },
    [mouseLast, positionLast, setPosition],
  );

  return (
    <div
      className={clsx(
        'min-h-40', // size
        'relative', // layout
        'overflow-hidden', // overflow
        'border', // border
      )}
      onMouseDown={handleMouseDown}
      onMouseMove={handleMouseMove}
    >
      <div
        className={clsx(
          'w-8 h-8', // size
          'absolute', // layout
          'bg-green-500', // color
        )}
        style={{
          left: position.x,
          top: position.y,
        }}
      ></div>
    </div>
  );
}

Optimal Example

This next example uses a canvas for rendering the same effect.

In this example there are no React or DOM changes at all during drag. Since the position is not used in the component render, we don't need to store it in immutable state. If we did, the component would unnecessarily render and we'd be left wondering why canvas is slow.

function DragBlueSquare() {
  // canvas ref
  const canvas = useRef<HTMLCanvasElement | null>(null);

  // the mouse position
  const mouse = useRef<Point>({
    x: 0,
    y: 0,
  });

  // the position of the blue square
  const position = useRef<Point>({
    x: 0,
    y: 0,
  });

  // render the canvas
  const renderCanvas = useCallback(() => {
    if (canvas.current) {
      const ctx = canvas.current.getContext('2d');
      if (ctx) {
        ctx.fillStyle = 'blue';
        ctx.clearRect(0, 0, 384, 160);
        ctx.fillRect(position.current.x, position.current.y, 32, 32);
      }
    }
  }, [canvas, position]);

  // first render
  useEffect(() => {
    renderCanvas();
  }, [renderCanvas]);

  // mouse down
  const handleMouseDown = useCallback(
    (event: React.MouseEvent<HTMLCanvasElement>) => {
      mouse.current = {
        x: event.clientX,
        y: event.clientY,
      };
    },
    [mouse],
  );

  // mouse move
  const handleMouseMove = useCallback(
    (event: React.MouseEvent<HTMLCanvasElement>) => {
      if (event.buttons) {
        // calculate the distance of the mouse move
        const dx = event.clientX - mouse.current.x;
        const dy = event.clientY - mouse.current.y;

        // save the mouse position for the next move
        mouse.current = {
          x: event.clientX,
          y: event.clientY,
        };

        // save the new blue square position
        position.current = {
          x: position.current.x + dx,
          y: position.current.y + dy,
        };

        renderCanvas();
      }
    },
    [mouse, position],
  );

  return (
    <canvas
      ref={canvas}
      className={clsx(
        'w-96 h-40', // size
        'border', // border
      )}
      width={384}
      height={160}
      onMouseDown={handleMouseDown}
      onMouseMove={handleMouseMove}
    ></canvas>
  );
}

This example shows that useRef gives us a powerful tool to remove unnecessary work while still embracing React patterns. But what if we had a state library that could share common state across multiple components where each could use the state immutably or mutably as needed?

This is exactly what React Scopes gives us.