...and someone else's refs might be breaking yours! 😱
Table of contents
React ref
callback guarantees
React's ref
callbacks provide an important
guarantee (emphasis mine):
When you pass a different
ref
callback, React will call the previous callback’s cleanup function if provided. If no cleanup function is defined, theref
callback will be called withnull
as the argument. The next function will be called with the DOM node.
This means that the following code:
function MyDiv({ children }: { children?: ReactNode }) {
const ref = (div: HTMLDivElement) => {
console.log("Inserted", div);
return () => {
console.log("Removed", div);
};
};
return <div ref={ref}>{children}</div>;
}
function App() {
const [count, setCount] = useState(0);
const increaseCount = () => {
setCount((count) => count + 1);
};
return (
<div>
<MyDiv>Clicked {count} times.</MyDiv>
<button onClick={increaseCount}>Increase</button>
</div>
);
}
...will log:
Inserted
on initial mount, when the<div>
HTML element is inserted into the DOM.Removed
and thenInserted
on every<button>
click (or any other re-render). This happens regardless of actual DOM insertions/removals.Removed
on unmount, when the<div>
HTML element is removed from the DOM.
We can fix this issue by making the function reference stable across renders, for example, by wrapping it in
useCallback
:
function MyDiv({ children }: { children?: ReactNode }) {
// Now `ref` will be stable across renders since`useCallback` memoizes it
const ref = useCallback((div: HTMLDivElement) => {
console.log("Inserted", div);
return () => {
console.log("Removed", div);
};
}, []);
return <div ref={ref}>{children}</div>;
}
function App() {
const [count, setCount] = useState(0);
const increaseCount = () => {
setCount((count) => count + 1);
};
return (
<div>
<MyDiv>Clicked {count} times.</MyDiv>
<button onClick={increaseCount}>Increase</button>
</div>
);
}
...which will only log:
Inserted
on initial mount.Removed
on unmount.
Notice the callback is no longer called on button clicks.
Merging refs
Just like <div>
has a ref
prop to receive React refs, you often need to provide ref
props for your own components.
function TextInput({ ref }: { ref?: Ref<HTMLInputElement> }) {
return <input type="text" ref={ref} />;
}
// Now other components can access the internal <input> like this:
function App() {
const inputRef = useRef(null);
/* ... do stuff with `inputRef` ... */
return <TextInput ref={ref} />;
}
Low-level UI components often need to use their own refs while also forwarding external ones, like this:
function MyInput({ externalRef }: { externalRef?: Ref<HTMLInputElement> }) {
const inputRef = useRef<HTMLInputElement>(null);
/* ... do stuff with `inputRef` ... */
// How can we pass both `inputRef` and `externalRef` here?
return <input ref={/* ... */} />;
}
React does not offer a way to set two refs inside the ref
property (see facebook/react#29757), which means the
community has created many small utility libraries to solve this issue, like this:
import { mergeRefs } from "some-ref-merging-library";
function MyInput({ externalRef }: { externalRef?: Ref<HTMLInputElement> }) {
const inputRef = useRef<HTMLInputElement>(null);
/* ... do stuff with `inputRef` ... */
// Here we pass the combined refs to the underlying input
return <input ref={mergeRefs([externalRef, inputRef])} />;
}
Someone else's refs might be breaking your code
There are a few interesting use cases for refs. For example, we might want to focus an <input>
as soon as it is
inserted into the DOM:
function MyInput() {
const focusInput = useCallback((input: HTMLInputElement | null) => {
input?.focus();
}, []);
return <input ref={focusInput} type="text" />;
}
As we saw earlier, React's ref
callback guarantees ensure this works flawlessly
because the callback is only called once (when the <input>
is initially inserted into the DOM). But what happens
when we want to support an external ref
from props?
import { mergeRefs } from "some-ref-merging-library";
function MyInput({ externalRef }: { externalRef?: Ref<HTMLInputElement> }) {
const focusInput = useCallback((input: HTMLInputElement | null) => {
input?.focus();
}, []);
return <input ref={mergeRefs([externalRef, focusInput])} type="text" />;
}
Since mergeRefs
creates a new function on every render, its reference is by definition unstable, causing the same
issue we saw earlier. Can we fix it the same way as before?
Well, it depends.
External refs might be breaking your code
Let's fix the issue as we did earlier, by memoizing the merged ref so its reference is also stable across renders:
function MyInput({ externalRef }: { externalRef?: Ref<HTMLInputElement> }) {
const focusInput = useCallback((input: HTMLInputElement) => {
input?.focus();
}, []);
const ref = useMemo(
() => mergeRefs([externalRef, focusInput]),
// (1)
[externalRef, focusInput],
);
return <input ref={ref} type="text" />;
}
Can you spot the issue?
The problem with that code is that you don't control externalRef
so, if it is unstable, the dependencies in (1)
will invalidate and the memoized function will be recreated. If someone uses your component like this:
function App() {
const [count, setCount] = useState(0);
const increaseCount = () => {
setCount((count) => count + 1);
};
return (
<div>
<input type="text" />
<MyInput
// This ref is unstable!
externalRef={(input) => {
console.log(input);
}}
/>
Clicked {count} times.
<button onClick={increaseCount}>Increase</button>
</div>
);
}
...React will see a new function on every render, and your input will be focused every time the user clicks the "Increase" button.
Of course you could say that your component's consumer is breaking your component's API and they should just in turn memoize their ref callback as well. That's fine, but the problem is that...
Components using your ref are breaking your code
Since you are aware of this issue, you memoize all your callback refs to avoid breaking someone else's code:
import { SomeoneElsesInput } from "some-ui-library";
function App() {
const [count, setCount] = useState(0);
const increaseCount = () => {
setCount((count) => count + 1);
};
// We are good citizens and memoize our ref callbacks
const inputRef = useCallback((input: HTMLInputElement | null) => {
console.log("This should only log once, on mount", input);
}, []);
return (
<div>
<SomeoneElsesInput ref={inputRef} />
Clicked {count} times.
<button onClick={increaseCount}>Increase</button>
</div>
);
}
Again, can you spot the issue?
Even if you memoize your callback refs, SomeoneElsesInput
might merge them with unstable callbacks (possibly even from
third-party libraries) and create unstable callbacks. This pattern is common across React's ecosystem. The worst part is
you cannot fix it from userland without hacks—you would need to patch some-ui-library
, manually track reference
equality to ignore spurious callbacks (which is not always possible) or report the issue and wait for them to fix it.
Technically, merging your refs in this way could be a breaking change per semver, but this hidden interaction often violates semantic versioning guarantees without being obvious. Adding a ref (in your own private code) and merging it with an (already-existing) external ref doesn't seem to affect the public API, does it?
This is not a theoretical concern (guess how I found this issue?)
What can you do about it?
I reported the issue to the fantastic react-merge-refs
library, which is widely used in the ecosystem. Unfortunately, many
other libraries implement this small utility too and, to my knowledge,
none of them correctly handle this case (many don't even support React 19's cleanup functions for
refs!).
As we have seen, this leads to code breaking: (1) unknowingly, (2) at a distance, (3) both by the consumer and the provider, (4) in patch releases and (5) without recourse for the affected parties.
So I did what any self-respecting engineer would do: add another library to the mix. I published
react-best-merge-refs
to handle all these edge cases, with thorough
unit
and integration
tests.
As part of this work, I designed the utility with a very strict API to make it difficult to use incorrectly. In particular:
-
I only expose
useMergeRefs
and not a rawmergeRefs
that would return an always-unstable merged ref. This makes it essentially impossible to break other people's code. -
I changed the API to take a keyed object.
// This is my signature. function useMergeRefs<T>(refs: Record<string, Ref<T>>): Ref<T>; useMergeRefs({ externalRef, myRef }); // <- usage // Other libraries usually do this... function useMergeRefs<T>(refs: Ref<T>[]): Ref<T>; useMergeRefs([externalRef, myRef]); // ...or this. function useMergeRefs<T>(...refs: Ref<T>[]): Ref<T>; useMergeRefs(externalRef, myRef);
In this design, each ref has a unique key associated with it, which allows it to be tracked over time. This makes it very hard to use incorrectly and trigger a situation where an external ref might be made unstable under obscure circumstances.
The problem it solves (and the solution I came up with) is similar to React's
key
prop.See my rationale, where I explain why the usual APIs offered by other libraries (either an array argument or a variadic function taking refs) feel a bit footgun-y to me.
I'm not 100% convinced that (2)
is the perfect choice, but since the difference in usage is minimal ({}
instead of
[]
), I went for it in hopes of learning more through experience. It may only impact efficiency in hot paths, but I
strongly believe micro-optimizations should come after correctness. You can always expose an unsafe_mergeRefs
if
efficiency is critical.
I still believe facebook/react#29757 should be built into the framework: the implementation is very easy to get wrong and can break with React updates (as happened with React 19's introduction of cleanup functions for refs).
If you want a safer way to merge refs today, check out react-best-merge-refs
.
Will React ever fix this natively? We can only hope.
(Bonus track) Everything is breaking everything
(The plot thickens.)
I'll leave these as an exercise for the reader but, trust me, they are subtly buggy. Can you spot why?
Exhibit A
function App() {
const [count, setCount] = useState(0);
const increaseCount = () => {
setCount((count) => count + 1);
};
// We are good citizens and memoize our ref callbacks
const inputRef = useCallback(
(input: HTMLInputElement | null) => {
console.log(input, count);
},
[count],
);
return (
<div>
<MyInput ref={inputRef} autoFocus />
Clicked {count} times.
<button onClick={increaseCount}>Increase</button>
</div>
);
}
Exhibit B
function App() {
const [count, setCount] = useState(0);
const increaseCount = () => {
setCount((count) => count + 1);
};
// We are good citizens and memoize our ref callbacks
const inputRef = useCallback((input: HTMLInputElement | null) => {
console.log(input);
}, []);
return (
<div>
<MyInput ref={count > 10 ? inputRef : undefined} autoFocus />
Clicked {count} times.
<button onClick={increaseCount}>Increase</button>
</div>
);
}