React Callback Refs: What They Are and How to Use Them
Learn how React callback refs enhance DOM manipulation, address common issues, and enable advanced component interactions.
Join the DZone community and get the full member experience.
Join For FreeDuring development, we often need direct interaction with DOM elements. In such cases, React provides us with a mechanism called refs, which allows access to elements after they have been rendered. Most commonly, we use standard object refs via useRef
(let’s call them that), but there is another approach known as callback refs. This method offers additional flexibility and control over the lifecycle of elements, enabling us to perform certain specific actions at precise moments when elements are attached or detached from the DOM.
In this article, I want to explain what callback refs are and how they work, discuss the pitfalls you might encounter, and show examples of their usage.
What Are Callback Refs and How Do They Work?
Callback refs give you more granular control over ref attachment compared to object refs. Let’s take a look at how they work in practice:
- Mounting. When an element mounts into the DOM, React calls the ref function with the DOM element itself. This allows you to perform actions with the element immediately after it appears on the page.
- Unmounting. When an element unmounts, React calls the ref function with
null
. This gives you the opportunity to clean up or cancel any actions associated with that element.
Example: Tracking Mount and Unmount
import React, { useCallback, useState } from 'react';
function MountUnmountTracker() {
const [isVisible, setIsVisible] = useState(false);
const handleRef = useCallback((node: HTMLDivElement | null) => {
if (node) {
console.log('Element mounted:', node);
} else {
console.log('Element unmounted');
}
}, []);
return (
<div>
<button onClick={() => setIsVisible((prev) => !prev)}>
{isVisible ? 'Hide' : 'Show'} element
</button>
{isVisible && <div ref={handleRef}>Tracked element</div>}
</div>
);
}
export default MountUnmountTracker;
Each time we toggle the visibility of the element, the handleRef
function is called with either the node or null
, allowing us to track the moment the element is attached or detached.
Common Issues and Solutions
Issue: Repeated Callback Ref Invocations
A frequent issue when using callback refs is the repeated creation of the ref function on each re-render of the component. Because of this, React thinks it’s a new ref, calls the old one with null
(cleaning it up) and then initializes the new one — even if our element or component has not actually changed. This can lead to unwanted side effects.
Example of the Problem
Consider a Basic
component that has a button for toggling the visibility of a div
with a callback ref, plus another button to force a component re-render:
import React, { useState, useReducer } from 'react';
function Basic() {
const [showDiv, setShowDiv] = useState(false);
const [, forceRerender] = useReducer((v) => v + 1, 0);
const toggleDiv = () => setShowDiv((prev) => !prev);
const refCallback = (node: HTMLDivElement | null) => {
console.log('div', node);
};
return (
<div>
<button onClick={toggleDiv}>Toggle Div</button>
<button onClick={forceRerender}>Rerender</button>
{showDiv && <div ref={refCallback}>Example div</div>}
</div>
);
}
export default Basic;
Every time you click on the Rerender button, the component re-renders, creating a new refCallback
function. As a result, React calls the old refCallback(null)
and then the new refCallback(node)
, even though our element with the ref has not changed. In the console, you’ll see div null
and then div [node]
in turn, repeatedly. Obviously, we usually want to avoid unnecessary calls like that.
Solution: Memoizing the Callback Ref With useCallback
Avoiding this is quite straightforward: just use useCallback
to memoize the function. That way, the function remains unchanged across re-renders, unless its dependencies change.
import React, { useState, useCallback, useReducer } from 'react';
function Basic() {
const [showDiv, setShowDiv] = useState(false);
const [, forceRerender] = useReducer((v) => v + 1, 0);
const toggleDiv = () => setShowDiv((prev) => !prev);
const refCallback = useCallback((node: HTMLDivElement | null) => {
console.log('div', node);
}, []);
return (
<div>
<button onClick={toggleDiv}>Toggle Div</button>
<button onClick={forceRerender}>Rerender</button>
{showDiv && <div ref={refCallback}>Example div</div>}
</div>
);
}
export default Basic;
Now, refCallback
is created only once, on the initial render. It will not trigger extra calls on subsequent re-renders, preventing unnecessary callbacks and improving performance.
The Order of Callback Refs, useLayoutEffect, and useEffect
Before we talk about how to use callback refs in your code to solve specific problems, let’s understand how callback refs interact with the useEffect
and useLayoutEffect
hooks so that you can properly organize resource initialization and cleanup.
Execution Order
- callback ref – called immediately after rendering DOM elements, before effect hooks are run
- useLayoutEffect – runs after all DOM mutations but before the browser paints
- useEffect – runs after the component has finished rendering to the screen
import React, { useEffect, useLayoutEffect, useCallback } from 'react';
function WhenCalled() {
const refCallback = useCallback((node: HTMLDivElement | null) => {
if (node) {
console.log('Callback ref called for div:', node);
} else {
console.log('Callback ref detached div');
}
}, []);
useLayoutEffect(() => {
console.log('useLayoutEffect called');
}, []);
useEffect(() => {
console.log('useEffect called');
}, []);
return (
<div>
<div ref={refCallback}>Element to watch</div>
</div>
);
}
export default WhenCalled;
Console Output
Callback ref called for div: [div element]
useLayoutEffect called
useEffect called
This sequence tells us that callback refs are triggered before hooks like useLayoutEffect
and useEffect
, which is essential to keep in mind when writing your logic.
Which Problems Do Callback Refs Solve in Code?
First, let’s reproduce a problem typically encountered with regular object refs so we can then solve it with callback refs.
import { useCallback, useEffect, useRef, useState } from 'react';
interface ResizeObserverOptions {
elemRef: React.RefObject<HTMLElement>;
onResize: ResizeObserverCallback;
}
function useResizeObserver({ elemRef, onResize }: ResizeObserverOptions) {
useEffect(() => {
const element = elemRef.current;
if (!element) {
return;
}
const resizeObserver = new ResizeObserver(onResize);
resizeObserver.observe(element);
return () => {
resizeObserver.unobserve(element);
};
}, [onResize, elemRef]);
}
export function UsageDom() {
const [bool, setBool] = useState(false);
const elemRef = useRef<HTMLDivElement>(null);
const handleResize = useCallback((entries: ResizeObserverEntry[]) => {
console.log('resize', entries);
}, []);
useResizeObserver({ elemRef, onResize: handleResize });
const renderTestText = () => {
if (bool) {
return <p ref={elemRef}>Test text</p>;
}
return <div ref={elemRef}>Test div</div>;
};
return (
<div style={{ width: '100%', textAlign: 'center' }}>
<button onClick={() => setBool((v) => !v)}>Toggle</button>
{renderTestText()}
</div>
);
}
We won’t dive into every detail here. In short, we’re tracking the size of our div
or p
element via a ResizeObserver
. Initially, everything works fine: on mount, we can get the element’s size, and resizes are also reported in the console.
The real trouble starts when we toggle state to switch the element we’re observing. When we change state and thus replace the tracked element, our ResizeObserver
no longer works correctly. It keeps observing the first element, which is already removed from the DOM! Even toggling back to the original element doesn’t help because the subscription to the new element never properly attaches.
Note: The following solution is more representative of what you might write in a library context, needing universal code. In a real project, you might solve it through a combination of flags, effects, etc. But in library code, you don’t have knowledge of the specific component or its state. This is exactly the scenario where callback refs can help us.
import { useCallback, useRef, useState } from 'react';
function useResizeObserver(onResize: ResizeObserverCallback) {
const roRef = useRef<ResizeObserver | null>(null);
const attachResizeObserver = useCallback(
(element: HTMLElement) => {
const resizeObserver = new ResizeObserver(onResize);
resizeObserver.observe(element);
roRef.current = resizeObserver;
},
[onResize]
);
const detachResizeObserver = useCallback(() => {
roRef.current?.disconnect();
}, []);
const refCb = useCallback(
(element: HTMLElement | null) => {
if (element) {
attachResizeObserver(element);
} else {
detachResizeObserver();
}
},
[attachResizeObserver, detachResizeObserver]
);
return refCb;
}
export default function App() {
const [bool, setBool] = useState(false);
const handleResize = useCallback((entries: ResizeObserverEntry[]) => {
console.log('resize', entries);
}, []);
const resizeRef = useResizeObserver(handleResize);
const renderTestText = () => {
if (bool) {
return <p ref={resizeRef}>Test text</p>;
}
return <div ref={resizeRef}>Test div</div>;
};
return (
<div style={{ width: '100%', textAlign: 'center' }}>
<button onClick={() => setBool((v) => !v)}>Toggle</button>
{renderTestText()}
</div>
);
}
As you can see, we rewrote our useResizeObserver
hook to use a callback ref. We just pass in a (memoized) callback that should fire on resize, and no matter how many times we toggle elements, our resize callback still works. That’s because the observer attaches to new elements and detaches from old ones at the exact time we want, courtesy of the callback ref.
The key benefit here is that the developer using our hook no longer needs to worry about the logic of adding/removing observers under the hood — we’ve encapsulated that logic within the hook. The developer just needs to pass in a callback to our hook and attach its returned ref to their elements.
Combining Multiple Refs Into One
Here’s another scenario where callback refs come to the rescue:
import { useEffect, useRef } from 'react';
import { forwardRef, useCallback } from 'react';
interface InputProps {
value?: string;
onChange?: React.ChangeEventHandler<HTMLInputElement>;
}
const Input = forwardRef(function Input(
props: InputProps,
ref: React.ForwardedRef<HTMLInputElement>
) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!inputRef.current) {
return;
}
console.log(inputRef.current.getBoundingClientRect());
}, []);
return <input {...props} ref={ref} />;
});
export function UsageWithoutCombine() {
const inputRef = useRef<HTMLInputElement | null>(null);
const focus = () => {
inputRef.current?.focus();
};
return (
<div>
<Input ref={inputRef} />
<button onClick={focus}>Focus</button>
</div>
);
}
In the code above, we have a simple input component on which we set a ref, grabbing it from props using forwardRef
. But how do we use inputRef
inside the Input
component if we also need the ref from the outside for something like focusing? Maybe we want to do something else in the input component itself, such as getBoundingClientRect
. Replacing the prop ref with our internal ref means focusing from the outside will no longer work. So, how do we combine these two refs?
That’s where callback refs help again:
import { useEffect, useRef } from 'react';
import { forwardRef, useCallback } from 'react';
type RefItem<T> =
| ((element: T | null) => void)
| React.MutableRefObject<T | null>
| null
| undefined;
function useCombinedRef<T>(...refs: RefItem<T>[]) {
const refCb = useCallback((element: T | null) => {
refs.forEach((ref) => {
if (!ref) {
return;
}
if (typeof ref === 'function') {
ref(element);
} else {
ref.current = element;
}
});
}, refs);
return refCb;
}
interface InputProps {
value?: string;
onChange?: React.ChangeEventHandler<HTMLInputElement>;
}
const Input = forwardRef(function Input(
props: InputProps,
ref: React.ForwardedRef<HTMLInputElement>
) {
const inputRef = useRef<HTMLInputElement>(null);
const combinedInputRef = useCombinedRef(ref, inputRef);
useEffect(() => {
if (!inputRef.current) {
return;
}
console.log(inputRef.current.getBoundingClientRect());
}, []);
return <input {...props} ref={combinedInputRef} />;
});
export function UsageWithCombine() {
const inputRef = useRef<HTMLInputElement | null>(null);
const focus = () => {
inputRef.current?.focus();
};
return (
<div>
<Input ref={inputRef} />
<button onClick={focus}>Focus</button>
</div>
);
}
Explanation
We implemented a useCombinedRef
hook where a developer can pass in standard refs, callback refs, and optionally null
or undefined
. The hook itself is just a useCallback
that loops over the refs array. If the argument is null
, we ignore it, but if the ref is a function, we call it with the element; if it’s a standard ref object, we set ref.current
to the element.
In this way, we merge multiple refs into one. In the example above, both getBoundingClientRect
inside the Input
component, and the external focus call will work correctly.
What Changed in React 19 Regarding Callback Refs?
Automatic Cleanup
React now automatically handles the cleanup of callback refs when elements unmount, making resource management simpler. Here’s an example the React team shows in their documentation:
<input
ref={(ref) => {
// ref created
// NEW: return a cleanup function to reset
// the ref when element is removed from DOM.
return () => {
// ref cleanup
};
}}
/>
You can read more details about it in the official blog post, where it’s mentioned that soon, cleaning up refs via null
might be deprecated, leaving a single standardized way to clean up refs.
Choosing Between Normal Refs and Callback Refs
- Use standard refs (
useRef
) when you just need simple access to a DOM element or want to preserve some value between renders without additional actions on attach or detach. - Use callback refs when you require more granular control over the element’s lifecycle, when you are writing universal code (for example, in your own library or package), or when you need to manage multiple refs together.
Conclusion
Callback refs in React are a powerful tool that gives developers extra flexibility and control when working with DOM elements. In most cases, standard object refs via useRef
are sufficient for everyday tasks, but callback refs can help with more complex scenarios like those we discussed above.
Opinions expressed by DZone contributors are their own.
Comments