React Ref Patterns

Real-world React ref patterns: click-outside detection, ref arrays, previous value tracking, ResizeObserver, and TypeScript typing.

TL;DR

  1. 01Use ref.current.contains() to detect clicks outside a component.
  2. 02Track a previous value by assigning to a ref inside useEffect.
  3. 03Combine useRef with ResizeObserver for accurate element measurements.

Tips

  1. 01Wrap the callback prop in useCallback in the parent to keep the dependency array stable and avoid re-registering the document listener on every render.

Warnings

  1. 01On the very first render, usePrevious returns undefined because no previous value exists. Guard with prevValue !== undefined before using it in comparisons.

Click-Outside Detection

The click-outside pattern is essential for dropdowns, modals, and tooltips. Attach a ref to the container, listen for mousedown on document, and call contains() to decide whether to close.

  • Full reusable hook:
    import { useRef, useEffect } from 'react';
    
    function useClickOutside(callback) {
      const ref = useRef(null);
    
      useEffect(() => {
        function handleMouseDown(e) {
          if (ref.current && !ref.current.contains(e.target)) {
            callback();
          }
        }
        document.addEventListener('mousedown', handleMouseDown);
        return () => document.removeEventListener('mousedown', handleMouseDown);
      }, [callback]);
    
      return ref;
    }
    
    function Dropdown() {
      const [open, setOpen] = useState(false);
      const dropdownRef = useClickOutside(() => setOpen(false));
    
      return (
        <div ref={dropdownRef}>
          <button onClick={() => setOpen(o => !o)}>Toggle</button>
          {open && <ul><li>Item 1</li><li>Item 2</li></ul>}
        </div>
      );
    }
  • Use mousedown rather than click so the close action fires before any click handler on the newly focused element. This prevents flickering when clicking between two dropdowns.
  • Guard with ref.current && before calling contains — the ref may be null if the component unmounted between renders.

Ref Arrays for Dynamic Lists

When you render a list with map and need a ref to each item, you can't call useRef in a loop (that violates the Rules of Hooks). Instead, store an array inside a single ref and populate it with callback refs.

  • Hold all item refs in one ref object and assign each slot via a callback ref:
    import { useRef } from 'react';
    
    function VirtualList({ items }) {
      const itemRefs = useRef([]);
    
      function scrollToItem(index) {
        itemRefs.current[index]?.scrollIntoView({ behavior: 'smooth' });
      }
    
      return (
        <>
          <button onClick={() => scrollToItem(5)}>Jump to #5</button>
          <ul>
            {items.map((item, i) => (
              <li
                key={item.id}
                ref={node => { itemRefs.current[i] = node; }}
              >
                {item.label}
              </li>
            ))}
          </ul>
        </>
      );
    }
  • The inline callback node => { itemRefs.current[i] = node; } runs each render, keeping the array in sync when items are added or removed. When a node unmounts React calls the callback with null, so you may want to filter: itemRefs.current = itemRefs.current.filter(Boolean) before use.
  • This pattern also works with Map keyed by item ID for sparse or keyed access:
    const refMap = useRef(new Map());
    // ...
    ref={node => {
      if (node) refMap.current.set(item.id, node);
      else refMap.current.delete(item.id);
    }}

Previous Value Tracking

React has no built-in way to read a previous prop or state value, but a ref updated inside useEffect gives you exactly that — the value from the render that just committed.

  • Generic usePrevious hook:
    import { useRef, useEffect } from 'react';
    
    function usePrevious(value) {
      const ref = useRef(undefined);
      useEffect(() => {
        ref.current = value; // runs AFTER render, so ref holds previous value during render
      }, [value]);
      return ref.current;
    }
    
    function PriceDisplay({ price }) {
      const prevPrice = usePrevious(price);
      const direction = price > prevPrice ? '↑' : price < prevPrice ? '↓' : '';
    
      return (
        <p>
          {price} {direction} (was {prevPrice ?? 'N/A'})
        </p>
      );
    }
  • How the timing works: during render, ref.current still holds the previous value because the effect hasn't fired yet. After React commits the DOM, the effect updates ref.current to the new value, ready for the next render.
  • Use this to skip an effect on the initial render by comparing current and previous values, or to animate differences between frames.

Measuring with ResizeObserver

ResizeObserver fires whenever a watched element's content box changes size. Combining it with useRef gives you live width and height measurements without polling or window resize events.

  • Full hook that returns live dimensions:
    import { useRef, useState, useEffect } from 'react';
    
    function useElementSize() {
      const ref = useRef(null);
      const [size, setSize] = useState({ width: 0, height: 0 });
    
      useEffect(() => {
        const node = ref.current;
        if (!node) return;
    
        const observer = new ResizeObserver(([entry]) => {
          const { width, height } = entry.contentRect;
          setSize({ width: Math.round(width), height: Math.round(height) });
        });
    
        observer.observe(node);
        return () => observer.disconnect(); // always clean up
      }, []);
    
      return [ref, size];
    }
    
    function ResponsiveCard() {
      const [cardRef, { width, height }] = useElementSize();
    
      return (
        <div ref={cardRef} style={{ resize: 'both', overflow: 'auto', padding: 16 }}>
          <p>{width} × {height}px — drag the corner to resize</p>
        </div>
      );
    }
  • ResizeObserver is available in all modern browsers (Chrome 64+, Firefox 69+, Safari 13.1+). For older environments, polyfill with resize-observer-polyfill.
  • The observer fires once immediately after observe() is called, so size is populated on mount without an extra effect or state initialization.

TypeScript: Typing Refs

TypeScript has two ref types. Choosing the right one avoids unsafe non-null assertions and keeps autocompletion accurate.

TypecurrentCreated byUse case
RefObject<T>T | null (readonly)useRef<T>(null)DOM nodes React controls
MutableRefObject<T>T (writable)useRef<T>(initialValue)Timer IDs, counters, previous values
  • DOM element ref — always start with null:
    const inputRef = useRef<HTMLInputElement>(null);
    // inputRef is RefObject<HTMLInputElement>
    // Safe access with optional chaining:
    inputRef.current?.focus();
  • Mutable value ref — supply a real initial value so TypeScript infers MutableRefObject:
    const timerId = useRef<ReturnType<typeof setTimeout> | null>(null);
    // Assign later:
    timerId.current = setTimeout(fn, 500);
    clearTimeout(timerId.current!);
  • Typing a component that accepts a ref (React 19 style):
    import { useRef, useImperativeHandle } from 'react';
    
    type InputHandle = {
      focus: () => void;
      clear: () => void;
    };
    
    type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
      ref?: React.Ref<InputHandle>;
    };
    
    function FancyInput({ ref, ...props }: InputProps) {
      const innerRef = useRef<HTMLInputElement>(null);
    
      useImperativeHandle(ref, () => ({
        focus: () => innerRef.current?.focus(),
        clear: () => { if (innerRef.current) innerRef.current.value = ''; },
      }));
    
      return <input ref={innerRef} {...props} />;
    }
    
    // Parent:
    const formRef = useRef<InputHandle>(null);
    <FancyInput ref={formRef} placeholder="Email" />
  • Avoid using as React.MutableRefObject<T> to force-cast DOM refs — this bypasses null safety. Instead, use optional chaining or an early-return null check.

FAQ