Why I stopped using React's useRef for DOM manipulation and started using callback refs
useRef seems like the obvious choice for DOM access, but callback refs handle dynamic elements and cleanup automatically.
Why I stopped using React's useRef for DOM manipulation and started using callback refs
I used to reach for useRef whenever I needed direct DOM access. It felt like the standard React pattern -- create a ref with useRef(null), attach it to a JSX element, then access the DOM node through ref.current. Most React documentation showed this approach, and it worked perfectly for simple cases like focusing an input or measuring element dimensions. Then I built a dynamic list component where items could be added, removed, and reordered, and I needed to scroll specific items into view based on user actions.
The breaking point came when I realized my refs were pointing to stale DOM nodes after the list updated. I had a useRef for each list item, but when items were removed or reordered, my ref array would get out of sync with the actual DOM structure. I'd call scrollIntoView on what I thought was the third item, only to scroll to a completely different element. When I needed to focus the first input in a dynamically generated form, I found myself writing complex logic to track which refs were valid and which were pointing to unmounted components.
That's when I discovered that callback refs solve the lifecycle problem automatically. Every time React attaches or detaches the ref, it calls your callback function -- with the DOM node when mounting, with null when unmounting. This means you always have the current state of the DOM, and you get automatic cleanup when components unmount.
The useRef approach that caused problems
Here's how I used to handle dynamic refs with useRef:
function TodoList({ todos }: { todos: Todo[] }) {
const todoRefs = useRef<(HTMLDivElement | null)[]>([]);
const scrollToTodo = (index: number) => {
// This could be stale or undefined
todoRefs.current[index]?.scrollIntoView();
};
useEffect(() => {
// Manually resize array when todos change
todoRefs.current = todoRefs.current.slice(0, todos.length);
}, [todos.length]);
return (
<div>
{todos.map((todo, index) => (
<div
key={todo.id}
ref={(el) => {
todoRefs.current[index] = el;
}}
>
{todo.title}
</div>
))}
</div>
);
}The problems with this approach are subtle but significant:
- Stale references: When items are removed, the array indices shift but the refs array does not update automatically
- Manual cleanup: I have to manually manage the refs array size when the list changes
- Index-based access: Using array indices makes the code fragile when items move around
The callback ref solution
Callback refs eliminate these issues by letting React manage the lifecycle:
function TodoList({ todos }: { todos: Todo[] }) {
const todoRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const setTodoRef = useCallback((id: string) => {
return (el: HTMLDivElement | null) => {
if (el) {
todoRefs.current.set(id, el);
} else {
todoRefs.current.delete(id);
}
};
}, []);
const scrollToTodo = (id: string) => {
todoRefs.current.get(id)?.scrollIntoView();
};
return (
<div>
{todos.map((todo) => (
<div
key={todo.id}
ref={setTodoRef(todo.id)}
>
{todo.title}
</div>
))}
</div>
);
}Now the refs are managed by ID instead of index, and React automatically calls my callback with null when components unmount, cleaning up the Map automatically.
When callback refs really shine
The power of callback refs becomes obvious in complex scenarios:
- Dynamic lists: Items can be added, removed, or reordered without breaking your DOM references
- Conditional rendering: When elements appear and disappear based on state, callback refs handle the cleanup automatically
- Portal components: Elements that render outside their parent component tree still get proper ref cleanup
- Animation libraries: Third-party libraries that manipulate DOM nodes need reliable references that stay current
I've found callback refs particularly useful when building components that need to:
- Focus specific form fields after validation errors
- Scroll to newly added items in infinite lists
- Measure element dimensions for layout calculations
- Integrate with DOM-based libraries like chart renderers or text editors
The mental model shift
The key insight is that refs should be tied to data identity, not render order. When I used useRef with arrays, I was essentially saying "give me the third element" without considering that the third element might change. With callback refs and a Map, I'm saying "give me the element for this specific todo" -- which is what I actually want.
This approach also makes the code more resilient to refactoring. If I change how todos are filtered or sorted, the ref logic continues to work because it's based on stable IDs rather than fragile array indices.
Callback refs require a slight mental shift from imperative to declarative thinking, but they eliminate entire classes of bugs that come with manually managing ref lifecycles. React handles the timing for you, so you can focus on what you want to do with the DOM elements instead of when they're available.