DEV Community

Cover image for Merging refs
Anton Korzunov
Anton Korzunov

Posted on • Edited on

Merging refs

TL;DR - useRef and createRef are not the only API calls you might be interested in 👨‍🔬

Let's imagine - you are opening a React Hooks API Reference, looking for the available hooks, and then... your eyes spot a strange one, you probably missed before - useImperativeHandle.
You never used it, and probably never needed it, and have no idea what it does. And the provided example is not very helpful to understand the use case.
So what is it?

function FancyInput(props, ref) {
  const inputRef = useRef();
  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current.focus();
    }
  }));
  return <input ref={inputRef} ... />;
}
FancyInput = forwardRef(FancyInput);
Enter fullscreen mode Exit fullscreen mode

Well, as I just said - that's not very helpful. I think nobody understands what's written here 🤔. Why it's written here 🤷‍♂️. Let's fix this with a just one line.

function FancyInput(props, ref) {
  const inputRef = useRef();
  useImperativeHandle(ref, () => ({
+   // this is the PUBLIC API, we are exposing to a consumer via `ref`
    focus: () => {
      inputRef.current.focus();
    }
  }));
  return <input ref={inputRef} ... />;
}
FancyInput = forwardRef(FancyInput);
Enter fullscreen mode Exit fullscreen mode

In other words - once someone would <FancyInput ref={ref} /> that ref would contain only .focus.

Here is a codesanbox you can play with - https://codesandbox.io/s/react-hook-useimperativehandle-64e5j

The power of useImperativeHandle is the power of Bridge or, to be more concrete - a Facade pattern (you might know it as a wrapper).

However, useImperativeHandle is 100% useless, and powerless against the reality - I would prove it below.

Let's decompose everything to the atomic operations, to understand what do we actually need, and what's yet missing.

1. I want to do something when Ref changes

Let's imagine you want to expose different states depending on the ref value, because different refs might mean something different. However, the wrapper(and useImperativeHandle "exposes" a wrapper) you are exposing to the parent is always static. That's ok for some cases, but not ok for others. For example, you can't expose a real ref - only a wrapper around it, always a wrapper.

You need something like useImperativeHandle(ref, inputRef) to synchronize values between ref.current and inputRef.current, but it does not work that wat - only the useImperativeHandle(ref, () => inputRef.current) would work, but it will NOT update value when inputRef changes. There is no way you can react to inputRef.current changes

You might try to use dependency list for the useImperativeHandle hook to handle this situation. Something like:

useImperativeHandle( 
   ref, 
   () => inputRef.current, 
   [mmm, butWhen, itsGoing, toChange] // 🤷‍♂️ 🤔
);
Enter fullscreen mode Exit fullscreen mode

Well, you do not depend on these dependencies(and eslint rules will not help you), but something you are attaching ref to - is. Even more - you might attach "your" ref to the same forwardProp component, which might do the same - you are not controlling what ref is. It's a black box, and there is no way you may safely predict how that blackbox works.

This pattern is very fragile and please don't ever use it. Keep in mind - the official documentation is asking not to use it as well.

The only way to make this pattern "stable" - is to know when ref is updated, and reactively update the parentRef.
How to do it? Use ref callback! And that is the official recommendation.

Hooks API Reference

And that's also not a great idea. Callbacks are not great, and probably that's why we have got RefObject.

How to use RefObject and be notified about the change? Easy, and es5 compatible.

function createCallbackRef(onChangeCallback) {
  let current = null;
  return {
    set current(newValue) {
      // new value set
      current=newValue;
      onChangeCallback(newValue);
    }
    get current() {
      // what shall I return?
      return current;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Object getters and setters are for the rescue. There is another article about this approach, explaining different use cases in depth:

2. I want to use Refs to DOM objects

useImperativeHandle is good, but it's not helpful when you need to work with real DOM elements.
The problem is still the same - to have a local ref, and synchronize it with the parent. You need to kill two refs with one rock. I mean - you need to update two like there is only one!

When developing low level UI components, it is common to have to use a local ref but also support an external one using React.forwardRef. Natively, React does not offer a way to set two refs inside the ref property. This is the goal of this small utility.

By fact - this is very needed and a very powerful feature, as useful as forwardRef itself - be able to maintain ref locally, still being transparent for the external consumer.

From some point of view, mergeRef IS a forwardRef, as long as it forwards a given ref, just to more than one location.

3. I want to update Refs

However, what is ref? It could be an object(ref object), it could be a function(callback ref). How to set the new value to the provided ref if you don't know what it is?

React and useImperativeHandle, as well as mergeRef, are hiding this logic inside, but you might need it in other cases. In useEffect for example.
So here you go:

function assignRef<T>(ref, value) {
  if (typeof ref === 'function') {
    ref(value);
  } else if (ref != null) {
    ref.current = value;
  }
  return ref;
}
Enter fullscreen mode Exit fullscreen mode

If​ in the future ref's shape would change again, as it did with React 16 release, it would be just one function to update.

4. I want to transform my Refs

Which (surprise!) is what useImperativeHandle does - it gives you a programmatic way to return something else, derived from the original ref.

So, if you want to:

  • have a local ref, referring to the real DOM node
  • expose ref with .focus method to a parent

Then you have to merge and transform

-useImperativeHandle(ref, () => ({
-  focus: () => {
-    inputRef.current.focus();
-  }
-}));
mergeRefs([innerRef, tranformRef(
  ref, 
  (current) => ({focus:() => current.focus()}
)])
Enter fullscreen mode Exit fullscreen mode

How to create transformRef? Well, we need some pieces we already had - callbackRef and assignRef

function transformRef(ref, transformer) {
  return createCallbackRef(
    value => assignRef(ref, transformer(value))
  )
}
Enter fullscreen mode Exit fullscreen mode

transformRef is a real "bridge" between two worlds, for example in this issue someone needed to pass a "ref, stored in a ClassComponent instance, to the parent".

const FocusLock = ({As}) => <As ref={ref} />

const ResizableWithRef = forwardRef((props, ref) =>
  <Resizable {...props} ref={i => i && ref(i.resizable)}/>
  // i is a ref to a Class Component with .resizable property
);
// ...
<FocusLock as={ResizableWithRef}>
Enter fullscreen mode Exit fullscreen mode
  • FocusLock expects ref to be a DOMNode
  • Resizable is a Class Component, so we are getting ref to a class instance
  • i => i && ref(i.resizable) - a callback ref - transfers i.resizable to a focus-lock ref, adaption to the API.

With transformRef it would look like

<Resizable {...props} ref={transformRef(ref, i => i.resizable)}/>
Enter fullscreen mode Exit fullscreen mode

Looking the same, but does not require ref to be a callback ref - you don't need to worry about how you are going to do something, only about what you need to do.

5. I want my refs not to remount every time

Not sure you have noticed, but the code above (yes - all code above) would work, but would not make you (and React) happy.

The problem is: when ref is changing, React would set null to the old one, and then set the right value to the new one. And, as long as all functions above were returning a new function, or a new object every time - every time they would cause an update to the local, and parent refs.

And if you are using callback refs, and running some effects basing on their values - then it would cause even bigger updates.

Let's don't do that. Let's use hooks to memoize ref

function useTransformRef(ref, transformer) {
  return useState(() => createCallbackRef(
    value => assignRef(ref, transformer(value))
  ))[0];
}
Enter fullscreen mode Exit fullscreen mode

Easy and sound - we are using useState with state fabric as an argument to create the ref only once, let our code execute more straightforward.

Conclusion

And there is a library, which already implemented all atoms I've provided above, tested and typed:

GitHub logo theKashey / use-callback-ref

🤙The same useRef, but it will callback

🤙 use-callback-ref 📞



The same useRef but it will callback: 📞 Hello! Your ref was changed






Travis


bundle size



Keep in mind that useRef doesn't notify you when its content changes. Mutating the .current property doesn't cause a re-render. If you want to run some code when React attaches or detaches a ref to a DOM node, you may want to use a callback ref instead .... useCallbackRef instead.

Hooks API Reference

Read more about use-callback pattern and use cases:

This library exposes helpers to handle any case related to ref lifecycle

  • useCallbackRef - react on a ref change (replacement for useRef)
    • createCallbackRef - - low level version of useCallbackRef
  • useMergeRefs - merge multiple refs together creating a stable return ref
    • mergeRefs - low level version of useMergeRefs
  • useTransformRef - transform one ref to another (replacement for useImperativeHandle)
    • transformRef - low level version of useTransformRef
  • useRefToCallback - convert RefObject…




  • assignRef
  • useCallbackRef/createCallbackRef
  • useMergeRefs/mergeRefs
  • useTransformRef/transformRef
  • useRefToCallback/refToCallback

For the record: All kudos for mergeRefs goes to Greg Berge and react-merge-refs

Top comments (1)

Collapse
 
rossmoody profile image
Ross Moody

Very helpful article. Thanks so much for taking the time to write it.