React callback refs: what, why, and why not

Callback refs are one of those features you don’t reach for every day—until you hit a tricky edge case. Then they’re perfect.

What’s a callback ref

Instead of passing an object ref (ref={myRef}) you pass a function:

const handleElementRef = (domNode) => {
  // domNode is a DOM element on mount/update, or null on unmount
};

<input ref={handleElementRef} />

React calls your function after commit with the DOM node. On unmount (or when the ref changes identity) it calls it again with null.

Why not just useRef?

Object refs (const el = useRef(null)) are great for stable, single nodes. But they don’t trigger effects when .current changes, and they’re less convenient when the actual DOM node instance can change (e.g., conditional markup, list items, portals). Callback refs let you react to “node appeared/disappeared” events.

How they work (in practice)

  • On mount: setRef(node)
  • On unmount or ref replacement: setRef(null)
  • If the callback function identity changes, React “detaches” the old one with null, then “attaches” the new one with the node.

Memoize the callback with useCallback when you need a stable identity.

Example #1 — Focus an input on mount

import { useCallback } from 'react';

export default function InputFocus() {
  const handleInputRef = useCallback((inputElement) => {
    if (inputElement) {
      inputElement.focus();
    }
  }, []);

  return (
    <input
      ref={handleInputRef}
      placeholder="I get focused on mount"
      type="text"
    />
  );
}

This is the “hello world” of callback refs. Minimal, no extra state, no effect needed.

Example #2 — Integrate a third-party widget (init + cleanup)

If you create/destroy something heavyweight (chart, map, editor), handle init on attach and cleanup on detach. Store the instance in a ref.

import { useCallback, useRef } from 'react';

// pretend this is your external lib
function initChart(hostElement) {
  const chartApi = {
    destroy() {
      // cleanup chart resources
    },
  };
  // ... mount the chart into hostElement ...
  return chartApi;
}

export default function Chart() {
  const chartInstance = useRef(null);

  const handleChartContainerRef = useCallback((containerElement) => {
    if (containerElement) {
      // Initialize chart when container mounts
      chartInstance.current = initChart(containerElement);
    } else {
      // Cleanup chart when container unmounts
      chartInstance.current?.destroy();
      chartInstance.current = null;
    }
  }, []);

  const containerStyles = {
    height: 240,
    width: '100%',
    border: '1px solid #ddd',
    borderRadius: 4,
  };

  return <div ref={handleChartContainerRef} style={containerStyles} />;
}

No extra re-renders, no useEffect necessary. The callback runs exactly when the node appears/disappears.

Example #3 — D3: render when the node appears (and on data change)

Here the callback ref captures the host element, and an effect handles rendering + cleanup. This pattern shines because useRef wouldn’t trigger the effect when the host changes.

import { useEffect, useState } from 'react';
import * as d3 from 'd3';

export default function D3Bars({ data }) {
  const [svgContainer, setSvgContainer] = useState(null);

  useEffect(() => {
    if (!svgContainer || !data?.length) return;

    // Chart dimensions
    const chartWidth = 320;
    const chartHeight = 120;
    const margin = { top: 10, right: 10, bottom: 10, left: 10 };

    // Create SVG
    const svg = d3
      .select(svgContainer)
      .append('svg')
      .attr('width', chartWidth)
      .attr('height', chartHeight);

    // Create scales
    const xScale = d3
      .scaleBand()
      .domain(data.map((_, index) => index))
      .range([margin.left, chartWidth - margin.right])
      .padding(0.1);

    const yScale = d3
      .scaleLinear()
      .domain([0, d3.max(data) || 0])
      .range([chartHeight - margin.bottom, margin.top]);

    // Create bars
    svg
      .selectAll('rect')
      .data(data)
      .join('rect')
      .attr('x', (_, index) => xScale(index))
      .attr('y', (value) => yScale(value))
      .attr('width', xScale.bandwidth())
      .attr('height', (value) => chartHeight - margin.bottom - yScale(value))
      .attr('fill', '#4f46e5');

    // Cleanup function
    return () => {
      svg.remove();
    };
  }, [svgContainer, data]);

  const containerStyles = {
    width: '100%',
    maxWidth: 320,
    border: '1px solid #e5e7eb',
    borderRadius: 8,
    padding: 8,
  };

  return <div ref={setSvgContainer} style={containerStyles} />;
}

Example #4 — Trigger an effect when the element changes

useRef changes don’t re-run effects; state does. Use a callback ref to capture the element into state, then run an effect.

import { useLayoutEffect, useState } from 'react';

export default function MeasureBox() {
  const [containerElement, setContainerElement] = useState(null);
  const [dimensions, setDimensions] = useState({ width: 0, height: 0 });

  useLayoutEffect(() => {
    if (!containerElement) return;

    const resizeObserver = new ResizeObserver(([entry]) => {
      const { width, height } = entry.contentRect;
      setDimensions({ width, height });
    });

    resizeObserver.observe(containerElement);

    return () => {
      resizeObserver.disconnect();
    };
  }, [containerElement]);

  const containerStyles = {
    resize: 'both',
    overflow: 'auto',
    padding: 8,
    border: '1px solid #ccc',
    minWidth: 100,
    minHeight: 50,
  };

  return (
    <div ref={setContainerElement} style={containerStyles}>
      <p>
        Size: {dimensions.width.toFixed(0)} × {dimensions.height.toFixed(0)}
      </p>
    </div>
  );
}

This is also handy if the node appears later (conditional UI) and you need to run logic immediately when it exists.

When callback refs are useful

  • Dynamic or conditional DOM: the actual node instance can change, and you need to react to that.
  • 3rd-party integrations: init on attach, cleanup on detach.
  • Measuring/layout work: when a node appears, you need to measure or observe it.
  • Portals: nodes mount in different trees; callbacks tell you exactly when they exist.

When not to use them

  • Simple, stable references → prefer useRef.
  • Exposing a DOM node or methods to parents → use forwardRef and, if needed, useImperativeHandle.
  • Layout reads/writes tied to paint → put the work in useLayoutEffect (you can still use a callback ref to get the node, but the heavy work belongs in the effect).

Pitfalls & how to avoid them

  • Callback identity churn
    Inline el => { ... } changes each render. React then calls your old ref with null and the new one with el, causing extra work.
    Fix: wrap in useCallback, or use a stable setter like setState as the ref.
  • Doing heavy work inside the ref
    The ref runs during commit. Don’t do expensive work or trigger multiple state updates there.
    Fix: store minimal info (e.g., set state with the node), then do the heavy work in an effect.
  • Forgetting cleanup
    If you create external objects (charts/editors), you must destroy them when the node goes away.
    Fix: handle cleanup when the callback receives null, or manage it in an effect’s cleanup.
  • Expecting return cleanups from the ref
    Returning a function from a ref callback does nothing; React ignores it.
    Fix: use the null call or an effect cleanup.
  • SSR assumptions
    Refs only exist on the client. Guard DOM access, and prefer useEffect/useLayoutEffect for DOM work.

Substitutes at a glance

  • useRef: stable handle to a node or mutable value; doesn’t cause re-renders. Best for simple cases.
  • forwardRef + useImperativeHandle: expose a controlled API from a child to a parent.
  • useLayoutEffect: do DOM reads/writes before paint; pair it with a callback ref or useRef.

Summary

Object refs cover 90% of cases. Callback refs cover the rest: dynamic nodes, precise attach/detach points, and integrations where you need to act the moment a node exists or disappears. Reach for them when you need that control; otherwise keep it simple with useRef.

This post was written 5 days ago.

If you liked this post tweet about it or follow me on Twitter for more content like this!