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
forwardRefand, 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
Inlineel => { ... }changes each render. React then calls your old ref withnulland the new one withel, causing extra work.
Fix: wrap inuseCallback, or use a stable setter likesetStateas 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 receivesnull, 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 thenullcall or an effect cleanup. - SSR assumptions
Refs only exist on the client. Guard DOM access, and preferuseEffect/useLayoutEffectfor 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 oruseRef.
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.