...and someone else's refs might be breaking yours! 😱

  1. React ref callback guarantees
  2. Merging refs
  3. Someone else's refs might be breaking your code
    1. External refs might be breaking your code
    2. Components using your ref are breaking your code
  4. What can you do about it?
  5. (Bonus track) Everything is breaking everything

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, the ref callback will be called with null 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>
  );
}

(CodeSandbox)

...will log:

  1. Inserted on initial mount, when the <div> HTML element is inserted into the DOM.
  2. Removed and then Inserted on every <button> click (or any other re-render). This happens regardless of actual DOM insertions/removals.
  3. 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>
  );
}

(CodeSandbox)

...which will only log:

  1. Inserted on initial mount.
  2. Removed on unmount.

Notice the callback is no longer called on button clicks.

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])} />;
}

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" />;
}

(CodeSandbox)

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" />;
}

(CodeSandbox)

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.

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>
  );
}

(CodeSandbox)

...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...

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?

(CodeSandbox)

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?)

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:

  1. I only expose useMergeRefs and not a raw mergeRefs that would return an always-unstable merged ref. This makes it essentially impossible to break other people's code.

  2. 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.


(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>
  );
}