React callback refs: what, why, and why not

Callback refs are a small React feature that solve real problems when object refs fall short. Here's how they work, when they shine, and when to avoid them.

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:

jsx
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 (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

jsx
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), init it on attach and clean it up on detach. Store the instance in a ref.

jsx
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: Render D3 when the node appears (and on data change)

Here the callback ref captures the host element, and an effect handles rendering + cleanup. The ref has to be state here, because useRef wouldn't trigger the effect when the host changes.

jsx
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.

jsx
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 and 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.

The alternatives

  • 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 most 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.