How to Optimize React Refs for Performance

React Refs (useRef) are powerful tools that allow you to directly access DOM nodes and persist mutable values across renders without triggering a re-render. However, inefficient use of refs can lead to memory leaks, redundant computations, and unpredictable component behavior. This article provides a direct, practical guide on how to optimize React Refs, focusing on avoiding common pitfalls like inline callback recreation, managing cleanup, and using callback refs efficiently.

Avoid Inline Callback Refs

When you pass an inline function as a ref, React calls it twice during every update: first with null and then with the DOM element. This happens because a new function instance is created on every render, forcing React to clear the old ref and set up the new one.

To optimize this, define the callback ref using useCallback so that the function identity remains stable across renders.

import { useCallback, useRef } from 'react';

function OptimizedInput() {
  // Use useCallback to memoize the ref callback
  const inputRef = useCallback((node) => {
    if (node !== null) {
      node.focus();
    }
  }, []); // Empty dependency array ensures stability

  return <input ref={inputRef} type="text" />;
}

Don’t Use Refs for Render-Critical Data

Refs do not trigger a re-render when their .current property changes. If you store data in a ref that is directly rendered in the JSX, your UI will become out of sync with your application state.

// BAD: UI will not update when count changes
const countRef = useRef(0);
return <button onClick={() => countRef.current++}>{countRef.current}</button>;

// GOOD: Use state for values rendered in the UI
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;

Clean Up Refs to Prevent Memory Leaks

When attaching external libraries, event listeners, or observers (like IntersectionObserver or ResizeObserver) to a ref, you must clean them up when the component unmounts to prevent memory leaks.

Always perform cleanup inside the return function of a useEffect hook.

import { useEffect, useRef } from 'react';

function ScrollTracker() {
  const containerRef = useRef(null);

  useEffect(() => {
    const element = containerRef.current;
    if (!element) return;

    const handleScroll = () => {
      console.log('Scrolled:', element.scrollTop);
    };

    element.addEventListener('scroll', handleScroll);

    // Cleanup to prevent memory leaks
    return () => {
      element.removeEventListener('scroll', handleScroll);
    };
  }, []);

  return <div ref={containerRef} style={{ overflowY: 'scroll', height: '100px' }}>Content</div>;
}

Optimize Parent-Child Communication with useImperativeHandle

Exposing an entire DOM node to a parent component via forwardRef can break encapsulation and lead to accidental mutations. You can optimize and secure this relationship by using useImperativeHandle to limit what the parent component can access.

import { useRef, useImperativeHandle, forwardRef } from 'react';

const CustomInput = forwardRef((props, ref) => {
  const inputRef = useRef(null);

  // Only expose specific methods to the parent component
  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current.focus();
    },
    clear: () => {
      inputRef.current.value = '';
    }
  }));

  return <input ref={inputRef} type="text" />;
});

Lazy Initialize Mutable Values

If you need to store a computationally expensive object (like a heavy class instance) in a ref, initializing it directly inside useRef will recreate the object on every render, even though the ref only keeps the value from the initial render.

// BAD: ExpensiveClass is instantiated on every single render
const apiRef = useRef(new ExpensiveClass());

To optimize this, initialize the ref with null and instantiate the object conditionally.

// GOOD: ExpensiveClass is only instantiated once
const apiRef = useRef(null);

function getApi() {
  if (apiRef.current === null) {
    apiRef.current = new ExpensiveClass();
  }
  return apiRef.current;
}