Chapter 7

useRef and DOM Manipulation: The Complete Ref Handbook

The Three Uses of Refs

useRef returns a mutable object { current: initialValue } that persists for the full lifetime of the component. Mutating .current does not trigger a re-render โ€” this is the fundamental distinction between refs and state.

These two properties together โ€” persistence across renders and mutation without re-rendering โ€” make refs irreplaceable in exactly three scenarios:

Use 1: Persisting mutable values across renders without causing re-renders. Timer IDs, WebSocket instances, previous render values, animation frame handles.

Use 2: Accessing DOM nodes for imperative operations โ€” focusing, measuring, scrolling, integrating with non-React libraries.

Use 3: Breaking stale closures by storing the latest version of a callback or state value so that stable callbacks can always access current data.

Understanding which use case you are in determines every decision about how to use refs correctly.

Persisting Mutable Values: Data That Should Not Live in State

Not every value that needs to survive re-renders belongs in state. If a value changes but those changes should not cause the UI to update, putting it in state adds unnecessary render cycles. Refs are the right home for this data.

function StopWatch() {
  const [elapsedMs, setElapsedMs] = useState(0);
  const intervalRef = useRef<number | null>(null);

  function start() {
    if (intervalRef.current !== null) return; // already running
    const startTime = Date.now() - elapsedMs;
    intervalRef.current = window.setInterval(() => {
      setElapsedMs(Date.now() - startTime);
    }, 10);
  }

  function stop() {
    clearInterval(intervalRef.current!);
    intervalRef.current = null;
  }

  return (
    <div>
      <p>{(elapsedMs / 1000).toFixed(2)}s</p>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
    </div>
  );
}

intervalRef holds the timer ID โ€” changes to it have no UI consequence, so ref is correct. elapsedMs drives the display โ€” it must be state. The distinction is not about what kind of value it is, but about whether changes to it should trigger re-renders.

Storing the Previous Render's Value

A classic ref pattern is comparing the current value to the one from the previous render:

function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T | undefined>(undefined);
  useEffect(() => {
    ref.current = value;
  }); // No deps array: runs after every render

  return ref.current;
}

function PriceDisplay({ price }: { price: number }) {
  const prevPrice = usePrevious(price);
  const trend =
    prevPrice === undefined ? 'neutral'
    : price > prevPrice ? 'up'
    : price < prevPrice ? 'down'
    : 'neutral';

  return <span className={`price ${trend}`}>{price}</span>;
}

The subtle timing here is deliberate: during the render where PriceDisplay reads ref.current, that value is still the previous render's value because the effect that updates it runs after the render completes. This gives us "what was the value last render" semantics โ€” exactly what we need.

DOM Access: Imperative Operations the Right Way

React's default mode is declarative: describe what the UI should look like, and React handles the DOM. But some operations are inherently imperative and have no declarative equivalent:

function AutoFocusInput() {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  return <input ref={inputRef} placeholder="Focused automatically" />;
}

Passing a ref object to a JSX element's ref prop causes React to assign the DOM node to ref.current when the component mounts, and reset it to null when the component unmounts.

Never Read Ref.current During Render

This is the most common ref mistake:

function Wrong() {
  const ref = useRef<HTMLDivElement>(null);

  // Bug: ref.current is null on the first render
  // and reading it in render is not reliable even afterwards
  const width = ref.current?.offsetWidth ?? 0;

  return (
    <div ref={ref} style={{ fontSize: width > 500 ? '20px' : '16px' }}>
      Content
    </div>
  );
}

During the first render, ref.current is always null โ€” the DOM node does not exist yet. Even on subsequent renders, reading ref in the render phase is unreliable because React may render without immediately committing to the DOM (in concurrent mode). The DOM ref is only valid inside effects and event handlers.

function Correct() {
  const ref = useRef<HTMLDivElement>(null);
  const [fontSize, setFontSize] = useState(16);

  useLayoutEffect(() => {
    if (ref.current) {
      setFontSize(ref.current.offsetWidth > 500 ? 20 : 16);
    }
  }); // Runs after every commit

  return <div ref={ref} style={{ fontSize }}>Content</div>;
}

useLayoutEffect fires after DOM mutations but before the browser paints, making it the right place to measure DOM and update state based on that measurement without causing visible flicker.

forwardRef: Passing Refs Across Component Boundaries

By default, you cannot pass a ref to a custom component โ€” React will warn rather than forwarding the ref to a DOM node inside. For reusable component libraries where parent components legitimately need imperative access to a child's DOM node, React 18 and earlier provided forwardRef:

// React 18 and earlier
const CustomInput = forwardRef<HTMLInputElement, { label: string; placeholder?: string }>(
  function CustomInput({ label, placeholder }, ref) {
    return (
      <label>
        <span>{label}</span>
        <input ref={ref} placeholder={placeholder} />
      </label>
    );
  }
);

// Parent component
function LoginForm() {
  const usernameRef = useRef<HTMLInputElement>(null);

  function focusUsername() {
    usernameRef.current?.focus();
  }

  return (
    <>
      <CustomInput label="Username" ref={usernameRef} />
      <button onClick={focusUsername}>Focus Username</button>
    </>
  );
}

forwardRef transforms ref from an opaque, unreachable special prop into an explicit second parameter, giving the component author control over where the ref gets attached.

React 19: Ref as a Regular Prop

React 19 eliminates the need for forwardRef. You can now pass ref directly as a regular prop, like any other prop:

// React 19: no forwardRef needed
function CustomInput({
  label,
  placeholder,
  ref,
}: {
  label: string;
  placeholder?: string;
  ref?: React.Ref<HTMLInputElement>;
}) {
  return (
    <label>
      <span>{label}</span>
      <input ref={ref} placeholder={placeholder} />
    </label>
  );
}

// Usage is identical โ€” the calling code doesn't change
function LoginForm() {
  const usernameRef = useRef<HTMLInputElement>(null);
  return <CustomInput label="Username" ref={usernameRef} />;
}

This is one of React 19's most celebrated quality-of-life improvements. forwardRef created an extra wrapper component that showed up in DevTools, making component hierarchies noisier than they needed to be. React 19's ref-as-prop treats ref like what it conceptually is: a special prop that happens to be wired to DOM after mounting.

Backward compatibility: forwardRef still works in React 19. Existing code does not need migration. New code should prefer the prop pattern.

useImperativeHandle: Exposing a Controlled Imperative API

Sometimes giving a parent access to the entire DOM node is too permissive. A parent that holds a raw HTMLVideoElement ref can call any method on it โ€” including ones that break your component's invariants. useImperativeHandle lets you expose only the specific operations you intend to support:

type VideoPlayerHandle = {
  play(): void;
  pause(): void;
  seekTo(timeSeconds: number): void;
};

// React 18 style
const VideoPlayer = forwardRef<VideoPlayerHandle, { src: string }>(
  function VideoPlayer({ src }, ref) {
    const videoRef = useRef<HTMLVideoElement>(null);

    useImperativeHandle(ref, () => ({
      play() {
        videoRef.current?.play();
      },
      pause() {
        videoRef.current?.pause();
      },
      seekTo(time: number) {
        if (videoRef.current) {
          videoRef.current.currentTime = time;
        }
      },
    }), []); // empty deps: handle methods only reference the stable videoRef

    return <video ref={videoRef} src={src} />;
  }
);

// Parent can only call play/pause/seekTo โ€” cannot access the raw video element
function Cinema() {
  const playerRef = useRef<VideoPlayerHandle>(null);

  return (
    <>
      <VideoPlayer ref={playerRef} src="/feature.mp4" />
      <button onClick={() => playerRef.current?.play()}>Play</button>
      <button onClick={() => playerRef.current?.pause()}>Pause</button>
      <button onClick={() => playerRef.current?.seekTo(120)}>Skip to 2min</button>
    </>
  );
}

The third argument to useImperativeHandle is a dependency array following the same rules as useEffect. Since the handle functions only close over videoRef (which is stable), the empty array is correct here. If the handle depended on props or state, those would need to be in the deps.

When to Use useImperativeHandle

Good use cases:

Anti-patterns:

The Ref Callback Pattern

Instead of a ref object, you can pass a ref callback function as the ref prop. React calls it with the DOM node when mounting and with null when unmounting:

function MeasureOnMount() {
  const [height, setHeight] = useState<number | null>(null);

  // useCallback ensures the callback is stable โ€” important for correct behavior
  const measuredRef = useCallback((node: HTMLDivElement | null) => {
    if (node !== null) {
      setHeight(node.getBoundingClientRect().height);
    }
  }, []);

  return (
    <>
      <div ref={measuredRef}>Variable height content</div>
      {height !== null && <p>Height: {height}px</p>}
    </>
  );
}

An important detail: if the ref callback is an inline function (defined fresh each render), React calls it with null and then immediately with the new node on every render โ€” equivalent to a remount cycle. useCallback stabilizes the callback, so React only calls it when the DOM node actually mounts or unmounts.

Ref Callbacks for Tracking Multiple Nodes

When you need refs to a dynamic list of elements, using an index-based approach breaks when items reorder. A Map-based ref callback pattern handles this correctly:

function VirtualList({ items }: { items: Array<{ id: string; content: string }> }) {
  const nodeMap = useRef(new Map<string, HTMLLIElement>());

  function getItemRef(id: string) {
    return (node: HTMLLIElement | null) => {
      if (node) {
        nodeMap.current.set(id, node);
      } else {
        nodeMap.current.delete(id);
      }
    };
  }

  function scrollToItem(id: string) {
    nodeMap.current.get(id)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
  }

  return (
    <ul>
      {items.map(item => (
        <li key={item.id} ref={getItemRef(item.id)}>
          {item.content}
          <button onClick={() => scrollToItem(item.id)}>Scroll here</button>
        </li>
      ))}
    </ul>
  );
}

The map stays synchronized with the actual DOM โ€” items added, removed, or reordered are all correctly tracked.

The useLatest Pattern: Breaking Stale Closures with Refs

When you need a stable callback that always has access to the current value of some prop or state, refs solve the stale closure problem elegantly:

function useLatest<T>(value: T): React.MutableRefObject<T> {
  const ref = useRef(value);
  // Update during render (synchronous), before any effects run
  ref.current = value;
  return ref;
}

// Usage: stable event handler that always uses current onSave
function Editor({ content, onSave }: { content: string; onSave: (text: string) => void }) {
  const onSaveRef = useLatest(onSave);

  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      if (e.key === 's' && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        onSaveRef.current(content); // Always calls the latest onSave
      }
    }

    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [content, onSaveRef]); // onSaveRef is stable โ€” no re-subscription on every render
}

The ref is updated during render (synchronously), so by the time any effect or event handler runs, ref.current points to the latest value. This is a safe pattern as long as you only read ref.current inside effects and event handlers โ€” never during render.

Common Mistakes Summary

Mistake 1: Reading DOM ref during render ref.current is null on the first render and unreliable during concurrent rendering. Read it in effects and event handlers only.

Mistake 2: Using ref as a trigger for UI updates Mutating ref.current does not schedule a re-render. If the UI needs to reflect the change, use state. If it does not, use a ref.

Mistake 3: Including ref objects in effect dependency arrays A ref object returned by useRef is stable โ€” it is the same object for the entire lifetime of the component. Including it in deps is harmless but misleading; omitting it is correct.

Mistake 4: Inline ref callbacks without useCallback Inline ref callbacks run with null then the node on every render, causing the measured/registered node to go through an unnecessary unmount/remount cycle. Stabilize with useCallback.

Summary

useRef is React's bridge between the declarative rendering world and the imperative reality of DOM APIs and external systems. Its two defining properties โ€” stability across renders and mutation without re-rendering โ€” make it uniquely suited for three scenarios: persisting non-UI state, accessing DOM nodes imperatively, and breaking stale closures in stable callbacks.

React 19's ref-as-prop change eliminates the boilerplate of forwardRef and treats ref forwarding as what it always conceptually was: a normal prop with special timing. useImperativeHandle remains the right tool when precise control over the exposed API is more important than raw DOM access.

The mental model to carry forward: state drives the UI, refs coordinate with the imperative world. When you are unsure which to use, ask "does changing this value need to update what's displayed?" If yes, state. If no, ref.

Rate this chapter
4.6  / 5  (50 ratings)

๐Ÿ’ฌ Comments