You add useCallback to stabilize an event handler, but ref.current
returns null
or triggers infinite component loops. This is a common React problem that confuses even experienced developers.
The issue stems from a timing mismatch: useCallback
captures values during render, while React attaches refs after render is complete. This creates stale closures where your memoized function forever references the initial null
value.
This guide demonstrates the callback ref pattern—a reliable technique that solves this timing problem by letting React notify you when DOM nodes are available, ensuring your memoized functions always access the current elements while maintaining the performance benefits of useCallback
.
In Brief:
- Callback refs memoized with
useCallback
solve the timing problem whereref.current
returnsnull
, providing direct access to DOM elements when React mounts them - Implement the pattern in three steps: create a memoized callback function, attach it to the element's ref prop, and access the stored element in event handlers
- Use this approach for ResizeObserver measurements, animation libraries like GSAP, and managing dynamic lists without creating memory leaks or performance issues
- Apply callback refs when components re-render frequently or need immediate DOM access on mount, but stick with
useRef
for simple, static element references
Why ref.current Returns Null Inside useCallback
When you memoize a function with useCallback
and try to access ref.current
inside it, you'll hit an error because the ref is still null
. This happens due to React's lifecycle timing.
1import { useCallback, useRef, useEffect } from 'react';
2
3function Broken() {
4 const inputRef = useRef(null);
5
6 // Memoized once at render-time
7 const focusInput = useCallback(() => {
8 // Throws on first mount: ref.current is still null
9 inputRef.current.focus();
10 }, []);
11
12 useEffect(() => {
13 // Runs right after the component is painted
14 focusInput();
15 }, [focusInput]);
16
17 return <input ref={inputRef} />;
18}
useCallback
executes during React's render phase. At that moment, the DOM node hasn't been attached, so inputRef.current
is null
. The memoized function closes over this initial null
value, creating a stale closure. When useEffect
later calls focusInput
, it's still pointing at null
.
React attaches the element after render, but the memoized callback never sees that update. Refs are mutable objects that change without triggering re-renders or rebuilding closures.
You might try adding the ref to the dependency array:
1const focusInput = useCallback(() => {
2 inputRef.current?.focus();
3}, [inputRef]);
This doesn't work. inputRef
itself is stable—only its .current
property mutates. Including the ref object does nothing. Adding inputRef.current
won't work either since React expects stable references in dependency arrays.
This creates a performance paradox. You used useCallback
to avoid creating new functions each render, but the optimization fails if the memoized function never updates to see the real DOM node.
The solution is callback refs—functions React calls after the DOM node mounts. When memoized with useCallback
, the callback remains stable and always receives the current element, eliminating both stale closures and unnecessary re-creation.
Instead of wrestling with timing issues, you let React hand you the element post-render via a callback ref.
Get DOM Elements with useCallback in 3 Steps
Grabbing a live DOM node inside a functional component presents timing challenges—ref.current can be null immediately after mounting, but after mount, it synchronously points to the DOM node and does not lag behind updates.
The callback-ref pattern, memoized with useCallback
, intercepts the element at the exact moment React mounts or unmounts it. Here's how to implement this pattern effectively.
Step 1 – Create Your Callback Ref Function
Start by declaring a memoized function that React calls with the element reference:
1import { useCallback } from 'react';
2
3const refCallback = useCallback((element) => {
4 if (element) {
5 // element is now in the DOM
6 console.log('Width:', element.offsetWidth);
7 }
8}, []);
useCallback
preserves the function's identity between renders, so React only invokes it when the actual node changes. On mount, the element
parameter is the DOM node; on unmount, it's null
. This guarantees you work with the current node instead of a stale closure from an earlier render.
Use an empty dependency array unless the callback relies on changing props or state. This avoids a costly detach-attach cycle on every re-render, preventing unnecessary work.
For TypeScript, declare the function signature:
1const refCallback = useCallback<(element: HTMLDivElement | null) => void>(
2 (element) => {
3 /* … */
4 },
5 []
6);
Store the node based on your use case:
1// Reactive: triggers re-render when node changes
2const [node, setNode] = useState(null);
3const refCallback = useCallback((element) => setNode(element), []);
4
5// Non-reactive: access without re-render
6const nodeRef = useRef(null);
7const refCallback = useCallback((element) => { nodeRef.current = element; }, []);
Step 2 – Attach the Callback to Your Element
Wire the callback to the element's ref
prop:
1<div ref={refCallback}>
2 I'm measured as soon as I hit the DOM
3</div>
This differs from the classic const divRef = useRef(null); <div ref={divRef} />
pattern. With object refs, you poll divRef.current
later; with callback refs, you react immediately when React attaches the node.
Don't call the function directly:
1// ❌ React will execute this immediately and throw
2<div ref={refCallback()} />
Pass the reference: <div ref={refCallback} />
. This works with any HTML tag or component that forwards refs.
Step 3 – Access Your Element Inside Event Handlers
The callback ref fires before any effects, so you can safely use the stored node in other memoized callbacks:
1import { useRef, useCallback } from 'react';
2
3const elementRef = useRef(null);
4
5const refCallback = useCallback((node) => {
6 elementRef.current = node; // keep latest node
7}, []);
8
9const handleClick = useCallback(() => {
10 elementRef.current?.scrollIntoView({ behavior: 'smooth' });
11}, []);
12
13return (
14 <>
15 <button onClick={handleClick}>Scroll to box</button>
16 <div ref={refCallback} style={{ marginTop: 1000 }}>Target</div>
17 </>
18);
handleClick
never loses track of the element because elementRef.current
updates synchronously inside refCallback
. No additional useEffect
plumbing or dependency management needed—the pattern keeps your event handlers lean and renders free from infinite loops.
How to Use Callback Refs In Different Scenarios
Stable callback refs give you more than just DOM access—they become reliable lifecycle hooks for measurements, animations, and managing hundreds of elements without tanking performance. Here are some production-ready patterns.
Measuring Element Dimensions with ResizeObserver
You need element dimensions the moment they appear. Wiring useEffect
to useRef
works, but it fires at least one render late and gets messy when nodes change. A memoized callback ref skips the timing and cleanup headaches:
1import { useCallback, useRef, useState } from 'react';
2
3export function UseSize() {
4 const [size, setSize] = useState({ width: 0, height: 0 });
5 const observerRef = useRef(null); // stores the ResizeObserver instance
6
7 const measureRef = useCallback((node) => {
8 // Detach when the element unmounts or changes
9 if (observerRef.current) {
10 observerRef.current.disconnect();
11 observerRef.current = null;
12 }
13
14 if (node) {
15 observerRef.current = new ResizeObserver(([entry]) => {
16 const { width, height } = entry.contentRect;
17 setSize({ width, height });
18 });
19 observerRef.current.observe(node);
20 }
21 }, []); // empty deps → stable ref callback
22
23 return (
24 <>
25 <div ref={measureRef} style={{ resize: 'both', overflow: 'auto' }}>
26 Resize me
27 </div>
28 <p>{size.width}px × {size.height}px</p>
29 </>
30 );
31}
React calls measureRef
once with the element and again with null
when it leaves the DOM. The observer attaches exactly once and disconnects cleanly—no leaks, no repeated observers. The function identity never changes, so React doesn't detach/reattach the ref on every render.
This pattern shines when you're injecting variable-width content from a headless CMS like Strapi: measurement updates instantly as soon as the node exists, without an extra render cycle. This is particularly valuable when building responsive layouts with dynamic content that needs to adapt to various screen sizes.
Setting Up Animations with GSAP or Framer Motion
Animation libraries expect an element reference during initialization. If that reference flips between null
and a node on every re-render, timelines reset and flicker. A stable callback ref prevents those glitches:
1import { useCallback, useRef } from 'react';
2import { gsap } from 'gsap';
3
4export function FadeInCard() {
5 const tweenRef = useRef(null);
6
7 const cardRef = useCallback((node) => {
8 // Cleanup previous timeline
9 if (tweenRef.current) {
10 tweenRef.current.kill();
11 tweenRef.current = null;
12 }
13
14 // Init when the element mounts
15 if (node) {
16 tweenRef.current = gsap.fromTo(
17 node,
18 { opacity: 0, y: 20 },
19 { opacity: 1, y: 0, duration: 0.6 }
20 );
21 }
22 }, []);
23
24 return <article ref={cardRef} className="card">Content</article>;
25}
React guarantees the callback runs immediately after the element mounts, so GSAP receives a real DOM node on first render. When the component unmounts, React passes null
, letting you kill the timeline and avoid dangling animations.
Swap the GSAP call for animate
logic if you prefer Framer Motion—the principle stays identical.
Managing Refs for Dynamic Lists of Elements
Picture an infinite scroll list with 500 images that need lazy loading. Attaching a unique useRef
to every item creates hundreds of ref objects and forces manual juggling. Instead, memoize a factory that returns a callback ref bound to each item's key, and store nodes in a Map
:
1import { useCallback, useRef } from 'react';
2
3export function LazyList({ items }) {
4 // Map<id, HTMLElement>
5 const nodeMap = useRef(new Map());
6
7 // Factory returns a memoized callback ref for a given id
8 const getRef = useCallback((id) => {
9 return (node) => {
10 if (node) {
11 nodeMap.current.set(id, node);
12 } else {
13 nodeMap.current.delete(id); // cleanup on unmount
14 }
15 };
16 }, []); // stable factory
17
18 // IntersectionObserver set up once
19 const observerRef = useRef(null);
20 if (!observerRef.current) {
21 observerRef.current = new IntersectionObserver((entries) => {
22 entries.forEach(({ target, isIntersecting }) => {
23 if (isIntersecting) {
24 // Trigger image load or any side-effect
25 target.dataset.src && (target.src = target.dataset.src);
26 observerRef.current.unobserve(target);
27 }
28 });
29 });
30 }
31
32 return (
33 <ul>
34 {items.map(({ id, src }) => (
35 <li key={id}>
36 <img
37 data-src={src}
38 ref={(node) => {
39 // Combine storing with observing
40 getRef(id)(node);
41 if (node) observerRef.current.observe(node);
42 }}
43 alt="public discuss"
44 />
45 </li>
46 ))}
47 </ul>
48 );
49}
getRef
itself stays memoized, so each generated callback remains identical between renders for its item. React only calls it when the actual DOM node changes—not when parent state updates.
This scales gracefully: whether you have ten or a thousand elements, you avoid thrashing the IntersectionObserver or leaking references.
This pattern is especially effective when building content-rich applications that fetch data from Strapi's API. You can efficiently lazy-load images and other media assets from your Strapi Media Library while maintaining smooth scrolling performance, even with hundreds of content items.
These patterns eliminate entire classes of performance bugs—no more redundant observers, no broken timelines, and no re-render-induced null dereferences—while keeping your code concise and predictable.
Performance Benefits of Memoized Callback Refs
You reach for callback refs because you want speed, but the extra abstraction is only worth it when it actually eliminates work. To see why, compare three ways of wiring an input that auto-focuses:
1// 1. Inline callback – new function every render
2<input ref={node => node?.focus()} />
3
4// 2. Object ref – stable, but no notification on mount/unmount
5const inputRef = useRef(null);
6useEffect(() => {
7 inputRef.current?.focus();
8}, []);
9<input ref={inputRef} />
10
11// 3. Memoized callback ref – stable *and* notified
12const focusRef = useCallback(node => {
13 node?.focus();
14}, []);
15<input ref={focusRef} />
Open React DevTools Profiler and trigger a re-render (for example, by toggling local state). With pattern 1, the profiler shows two extra ref calls on every update: React first detaches the old function by passing null
, then attaches the new node.
Those redundant operations disappear entirely with pattern 3 because the function identity stays the same, preventing unnecessary ref lifecycle events.
When The Memoized Callback Ref Pays Off
Components that re-render frequently but must keep a DOM node alive see the biggest gains—think virtualized lists, chat message streams, or infinite scroll implementations. The stable function reference prevents React from detaching and reattaching the same element unnecessarily.
Props passed to React.memo
children benefit significantly from this pattern. React's useCallback documentation highlights how a stable ref callback prevents unnecessary child renders. When your component passes dozens of props to memoized children, every unstable reference forces a re-render.
Immediate side-effects on mount become more reliable with callback refs. Whether you're measuring with ResizeObserver
, wiring a GSAP timeline, or attaching IntersectionObserver listeners, the callback fires exactly once per actual node change. This avoids the double teardown/setup cycle that object refs often trigger.
When It's Overkill
If the element is static and you only need an occasional imperative call (for example, toggling focus in response to a button click), an object ref from useRef
works better.
The simpler approach requires less cognitive overhead and delivers identical performance for infrequent operations.
A quick decision tree
Use this framework to decide when callback refs make sense:
- Do you need to run logic the moment the element appears or disappears? • Yes → Use a callback ref.
- Will the component re-render often or pass the ref to memoized children? • Yes → Wrap the callback in
useCallback
. - Otherwise → Reach for
useRef
and keep the code lean.
The React Compiler (available experimentally in React 19) auto-stabilizes functions, potentially erasing much of the boilerplate you write around useCallback
—but until it's widely adopted in production and enabled by default, a memoized callback ref remains the most reliable tool for performance-critical DOM access.
Debugging Common Callback Ref Mistakes
When working with callback refs, you'll likely run into a few common roadblocks: functions that recreate themselves on every render, forgotten null checks, or closures that hold onto outdated values. Let's look at how to spot and fix these issues.
The most frequent trap is creating new callback functions on every render. React detaches the old ref (null
) and immediately re-attaches the new one, running side effects twice. If console logs fire on every state update, this is your culprit:
1// ❌ new function each render
2<input ref={(node) => node?.focus()} />
3
4// ✅ stable identity
5const setInputRef = useCallback(node => {
6 if (node) node.focus();
7}, []); // empty array keeps the function stable
8<input ref={setInputRef} />
Memoizing the callback stops the unnecessary detach/attach cycle and prevents layout jank.
Forgetting that React calls the ref with null
during cleanup creates the second common issue. Skip the null guard and you'll trigger animations or observers after the node is gone:
1const observeSize = useCallback(node => {
2 if (!node) return; // always guard
3 const observer = new ResizeObserver(() => { /* ... */ });
4 observer.observe(node);
5}, []);
Stale closures occur when the callback captures outdated props or state. ESLint's exhaustive-deps
rule flags this—add changing values to the dependency array unless the callback never reads them.
When debugging callback ref issues, use these three tools to identify the root cause:
console.log(node)
inside the callback to verify call frequency and values- React DevTools Profiler to confirm whether extra renders align with ref churn
- Chrome's memory panel to spot detached DOM nodes after navigation
These checks reveal whether you've hit the classic mistakes: unstable functions, missing null handling, or incorrect dependencies. Fix those and your callback refs behave predictably—even under Strict Mode's double render.
Get Reliable DOM Access with Callback Refs
Callback refs memoized with useCallback
provide stable function instances that React calls when DOM nodes mount or unmount, giving you access to live elements instead of stale null
values.
Since refs don't trigger re-renders, this approach prevents unnecessary node detachment/reattachment, reducing both computational work and layout thrashing. Consider replacing useRef
/useEffect
combinations with memoized callback refs to reduce re-renders while gaining immediate DOM access.
This pattern is especially valuable for content-rich applications built with headless CMS platforms like Strapi, where performance optimization becomes critical when rendering dynamic content.