HynekS
HynekS

Reputation: 3307

React custom hooks: returning a ref from hook vs passing the ref as hooks argument

Consider I have a custom hook that uses one or more refs to DOM elements, I see two distinct ways how to write/use a hook like that (and I know there are probably even more nuances – e. g. callback refs).

Option 1: return a ref from custom hook and use it in a component:

const Option1 = () => {
  const hooksRef = myCustomHookReturninARef()
  
  return <div ref={hooksRef}>Using returned ref as prop.</div>
}

Option 2: create a ref in a component and pass it to a custom hook:

const Option2 = () => {
  const componentsRef = React.createRef()
  
  myCustomHookExpectingRefArgument(componentsRef)
  
  return <div ref={componentsRef}>Using created ref as prop..</div>
}

I've been using either options and they both seems to be working fine. I know this is likely an opinionated question, but:

Are there some significant drawbacks using the first vs the second approach?

Upvotes: 10

Views: 4620

Answers (5)

Dom
Dom

Reputation: 40459

I went this direction:

import {useMemo} from 'react';

type CallbackRef<T> = (ref: T | null) => void;
type Ref<T> = React.MutableRefObject<T> | CallbackRef<T>;

function toFnRef<T>(ref?: Ref<T> | null) {
  if (!ref || typeof ref === 'function') {
    return ref;
  }
  return (value: T) => {
    ref.current = value;
  };
}

function mergeRefs<T>(refA?: Ref<T> | null, refB?: Ref<T> | null) {
  const a = toFnRef(refA);
  const b = toFnRef(refB);
  return (value: T | null) => {
    if (a) {
      a(value);
    }
    if (b) {
      b(value);
    }
  };
}

/**
 * Create and returns a single callback ref composed from two other Refs.
 *
 * @param refA A Callback or mutable Ref
 * @param refB A Callback or mutable Ref
 * @category refs
 */
export function useMergedRef<T>(refA?: Ref<T> | null, refB?: Ref<T> | null) {
  return useMemo(() => mergeRefs(refA, refB), [refA, refB]);
}

Upvotes: 0

Adnan Arif Sait
Adnan Arif Sait

Reputation: 113

The important detail is to pick one pattern and stick to it. Mixing the 2 patterns in your hooks could create a lot of pain down the line.

I lean towards option 1 - creating the ref in the custom hook. This is mainly because having the ref within the custom hook provides more flexibility, like setting it up as a callback ref.

The useHooks repo also follows the 1st pattern for their hooks. All their hooks generate refs internally and do not get the ref from the parent.

Why we chose Option 1

We ran into this issue recently. We had 2 hooks being used. The way we designed it was that the first hook would return a ref that we could pass into the 2nd hook.

const [hover, ref] = useHover();
useDetectClickOutside(ref)


return <div ref={ref}> ...

This worked fine for a while but later we ran into issues when the element being bound to the ref was conditionally being rendered. We wanted to change the useHover hook to use a callback ref which will allow us better control whenever the element changes or is removed.

Making this change however broke the useDetectClickOutside hook which expected a ref and not a callback function as the parameter.

Facing this we decided to standardize our custom hooks and always create the ref within the hook and pass it to the parent. We abstracted the way the hooks are merged so that we could handle both regular ref and callback refs.

const [hover, hoverRef] = useHover();
const clickRef = useDetectClickOutside(ref)


return <div ref={mergeRefs(hoverRef, clickRef)}> ...
function mergeRefs(...refs) {
  return (node) => {
    refs.forEach((ref) => {
      if (!ref) return;

      if (typeof ref === "function") {
        ref(node);
      } else {
        ref.current = node;
      }
    });
  };
}

Upvotes: 1

stealing_society
stealing_society

Reputation: 153

Really, it depends. When working with both Option 1 and Option 2, you would have to create extra variables and functions, depending on the use case, though those are typically negligible.

When it comes to performance, I would say they're virtually identical.

Personally, I'm much more of a fan of the modularity of Option 2, for these reasons:

  • Creating a ref before passing it into a hook allows you to mutate the ref beforehand (i.e. in a useEffect call). Consider the following:
export default () => {
  const ref = React.createRef<HTMLDivElement>();
  
  React.useEffect(() => {
    if(ref.current){
        ref.current.style.width = "120px";
    }
  }, []}

  const myCustomHook = useMyCustomHook(ref);

  return <div ref={ref} />
}

Of course, this functionality could be built into the hook to begin with, with a little foresight:

const useMyCustomHook = (ref: React.RefObject, mutateRefCallback: () => any {
  React.useEffect(() => {
    mutateRefCallback();
  }, []}

  // ...do other stuff
}

export default () => {
  const ref = React.createRef<HTMLDivElement>();

  const myCustomHook = useMyCustomHook(ref, () => {
    if(ref.current){
        ref.current.style.width = "120px";
    }
  });

  return <div ref={ref} />
}

But my opinion is that the latter creates unnecessarily complex code for what typically is a rare case.

  • Typically, creating a ref object and then passing it to a DOM node is cleaner and much more concise (i.e. when forwarding refs). As such, Option 1 becomes really cumbersome. Compare the following approaches:
// Option 1

const useMyFirstCustomHook = (inputRef: React.RefObject<HTMLInputElement>) => 
{
  //...do stuff
  return inputRef;
}

const useMySecondCustomHook = (buttonRef: React.RefObject<HTMLButtonElement>) => {
  //...do stuff
  return buttonRef;
}

type MyInputProps = {
  id: string;
  children?: string;
  placeholder?: string;
}

const MyInput = React.forwardRef((props: MyInputProps, ref) => {
  const {children, placeholder} = props;

  return (
    <label htmlFor={id}>
      {children}
      <input id={id} ref={ref} placeholder={placeholder} />
    </label>
  )
});

const MyForm = () => {
  /* Downside: Declaring refs and calling hooks separately 
leads to sometimes unnecessary code splitting. */
  const inputRef = useMyFirstCustomHook();
  const buttonRef = useMySecondCustomHook();

  return(
    <form>
      <MyInput placeholder="John Doe" ref={inputRef}>Name:</MyInput>
      <button type="button" 
        onClick={() => {
          //..do stuff
        }} 
      ref={buttonRef}>Save</button>
    </form>
  )
}

Since with Option 1 we can only have a single hook consuming a single ref at a time, our code becomes unnecessarily complicated. Here's an approach with Option 2:

const useMyCustomHook = (
  inputRef: React.RefObject<HTMLInputElement>,
  buttonRef: React.RefObject<HTMLButtonElement>) => 
{
  //...do stuff
  return {inputRef, buttonRef};
}

type MyInputProps = {
  id: string;
  children?: string;
  placeholder?: string;
}

const MyInput = React.forwardRef((props: MyInputProps, ref) => {
  const {children, placeholder} = props;

  return (
    <label htmlFor={id}>
      {children}
      <input id={id} ref={ref} placeholder={placeholder} />
    </label>
  )
});

const MyForm = () => {
  const inputRef = React.createRef<HTMLInputElement>();
  const buttonRef = React.createRef<HTMLButtonElement>();

  const myCustomHook = useMyCustomHook(inputRef, buttonRef);

  return(
    <form>
      <MyInput placeholder="John Doe" ref={inputRef}>Name:</MyInput>
      <button type="button" 
        onClick={() => {
          //..do stuff
        }} 
      ref={buttonRef}>Save</button>
    </form>
  )
}

Furthermore, Option 2 lets us mutate the refs based on one another a lot more easily.

Note that both approaches are valuable in their own way.

While Option 2 may be more modular, intuitive and clean in some regards, Option 1 is oftentimes a lot more straightforward and easy to use in common situations, such as a simple side-effect on mount.

Upvotes: 0

ukrutt
ukrutt

Reputation: 2399

I came here because I myself have the same dilemma :-). My 2 cents:

  • The hook user can be made more concise if the hook returns the ref since don't have to create it.
  • On the other hand, if the user already have a ref from a different ref-returning hook, they might have to combine two different refs. While that's maybe a straightforward task, it certainly tripped me up when I was in that situation.

Maybe one could get the best of both worlds, by both receiving and returning a ref? If the hook doesn't get a ref then it simply creates one. Either way it can return the ref.

Upvotes: 5

n--
n--

Reputation: 3856

not an React virtuoso, but from my perspective the only drawback using Option1 is that you have to validate the returned value to be a ref, from computing perspective there is no difference

Upvotes: 0

Related Questions