✨ Strapi MCP is now Generally Available - let your agents manage your Strapi content ✨

Ecosystem13 min read

Get DOM Elements with React useCallback

August 19, 2025Updated on June 14, 2026
DOM Elements with React useCallback

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 where ref.current returns null, 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.

import { useCallback, useRef, useEffect } from 'react';

function Broken() {
  const inputRef = useRef(null);

  // Memoized once at render-time
  const focusInput = useCallback(() => {
    // Throws on first mount: ref.current is still null
    inputRef.current.focus();
  }, []);

  useEffect(() => {
    // Runs right after the component is painted
    focusInput();
  }, [focusInput]);

  return <input ref={inputRef} />;
}

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:

const focusInput = useCallback(() => {
  inputRef.current?.focus();
}, [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:

import { useCallback } from 'react';

const refCallback = useCallback((element) => {
  if (element) {
    // element is now in the DOM
    console.log('Width:', element.offsetWidth);
  }
}, []);

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:

const refCallback = useCallback<(element: HTMLDivElement | null) => void>(
  (element) => {
    /* … */
  },
  []
);

Store the node based on your use case:

// Reactive: triggers re-render when node changes
const [node, setNode] = useState(null);
const refCallback = useCallback((element) => setNode(element), []);

// Non-reactive: access without re-render
const nodeRef = useRef(null);
const refCallback = useCallback((element) => { nodeRef.current = element; }, []);

Step 2 – Attach the Callback to Your Element

Wire the callback to the element's ref prop:

<div ref={refCallback}>
  I'm measured as soon as I hit the DOM
</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:

// ❌ React will execute this immediately and throw
<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:

import { useRef, useCallback } from 'react';

const elementRef = useRef(null);

const refCallback = useCallback((node) => {
  elementRef.current = node;       // keep latest node
}, []);

const handleClick = useCallback(() => {
  elementRef.current?.scrollIntoView({ behavior: 'smooth' });
}, []);

return (
  <>
    <button onClick={handleClick}>Scroll to box</button>
    <div ref={refCallback} style={{ marginTop: 1000 }}>Target</div>
  </>
);

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:

import { useCallback, useRef, useState } from 'react';

export function UseSize() {
  const [size, setSize] = useState({ width: 0, height: 0 });
  const observerRef = useRef(null); // stores the ResizeObserver instance

  const measureRef = useCallback((node) => {
    // Detach when the element unmounts or changes
    if (observerRef.current) {
      observerRef.current.disconnect();
      observerRef.current = null;
    }

    if (node) {
      observerRef.current = new ResizeObserver(([entry]) => {
        const { width, height } = entry.contentRect;
        setSize({ width, height });
      });
      observerRef.current.observe(node);
    }
  }, []); // empty deps → stable ref callback

  return (
    <>
      <div ref={measureRef} style={{ resize: 'both', overflow: 'auto' }}>
        Resize me
      </div>
      <p>{size.width}px × {size.height}px</p>
    </>
  );
}

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:

import { useCallback, useRef } from 'react';
import { gsap } from 'gsap';

export function FadeInCard() {
  const tweenRef = useRef(null);

  const cardRef = useCallback((node) => {
    // Cleanup previous timeline
    if (tweenRef.current) {
      tweenRef.current.kill();
      tweenRef.current = null;
    }

    // Init when the element mounts
    if (node) {
      tweenRef.current = gsap.fromTo(
        node,
        { opacity: 0, y: 20 },
        { opacity: 1, y: 0, duration: 0.6 }
      );
    }
  }, []);

  return <article ref={cardRef} className="card">Content</article>;
}

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:

import { useCallback, useRef } from 'react';

export function LazyList({ items }) {
  // Map<id, HTMLElement>
  const nodeMap = useRef(new Map());

  // Factory returns a memoized callback ref for a given id
  const getRef = useCallback((id) => {
    return (node) => {
      if (node) {
        nodeMap.current.set(id, node);
      } else {
        nodeMap.current.delete(id); // cleanup on unmount
      }
    };
  }, []); // stable factory

  // IntersectionObserver set up once
  const observerRef = useRef(null);
  if (!observerRef.current) {
    observerRef.current = new IntersectionObserver((entries) => {
      entries.forEach(({ target, isIntersecting }) => {
        if (isIntersecting) {
          // Trigger image load or any side-effect
          target.dataset.src && (target.src = target.dataset.src);
          observerRef.current.unobserve(target);
        }
      });
    });
  }

  return (
    <ul>
      {items.map(({ id, src }) => (
        <li key={id}>
          <img
            data-src={src}
            ref={(node) => {
              // Combine storing with observing
              getRef(id)(node);
              if (node) observerRef.current.observe(node);
            }}
            alt="public discuss"
          />
        </li>
      ))}
    </ul>
  );
}

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. Inline callback – new function every render
<input ref={node => node?.focus()} />

// 2. Object ref – stable, but no notification on mount/unmount
const inputRef = useRef(null);
useEffect(() => {
  inputRef.current?.focus();
}, []);
<input ref={inputRef} />

// 3. Memoized callback ref – stable *and* notified
const focusRef = useCallback(node => {
  node?.focus();
}, []);
<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:

  1. Do you need to run logic the moment the element appears or disappears? • Yes → Use a callback ref.
  2. Will the component re-render often or pass the ref to memoized children? • Yes → Wrap the callback in useCallback.
  3. 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:

// ❌ new function each render
<input ref={(node) => node?.focus()} />

// ✅ stable identity
const setInputRef = useCallback(node => {
  if (node) node.focus();
}, []);           // empty array keeps the function stable
<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:

const observeSize = useCallback(node => {
  if (!node) return;           // always guard
  const observer = new ResizeObserver(() => { /* ... */ });
  observer.observe(node);
}, []);

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:

  1. console.log(node) inside the callback to verify call frequency and values
  2. React DevTools Profiler to confirm whether extra renders align with ref churn
  3. 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.

Paul BratslavskyDeveloper Advocate

Related Posts

App·17 min read

How to Build a Bookmark app using Tailwind, React and Strapi

In this tutorial, we will build a bookmarking app to demonstrate how we can use Tailwind CSS framework and Reactjs to design, develop the app & learn how to consume our Strapi APIs from Reactjs.

·July 8, 2021
Next (React.js) and Strapi
How tosBeginner·33 min read

Build a blog with Next (React.js) and Strapi

Learn to create a blog website using Strapi for the back-end and Next.js for the front-end.

·December 12, 2019
App·24 min read

Build a News app using React.js, with i18n API in Strapi

This article is a guest post by . He wrote this blog post through the Write for the Community program. If you are...

·May 7, 2021