Tinkering with useImperativeHandle

Tinkering with useImperativeHandle

This is a small blog informing about my learnings after tinkering with useImperativeHandle.

One fine day at work, I was asked to implement a fix and while going through the codebase I came across this hook.

Until this point, even though I have been using React for some time now, I was not aware it. 🙂

I started looking into it from none other than react official docs. P.S. they are really well written. So, I won't be repeating same things here, but it's a perfect place to start to learn.

Basics:
So, with ref, you can do three things: Either pass it down Or attach it to a DOM(any native platform) Or useImperativeHandle that consumes it.

If not these, probably ref is not very useful.

  1. Passing it down is simple. You wrap the component with forwardRef and then its the responsibility of the parent(the consumer) to provide the ref prop. Ok, now here I also thought why to use forwardRef? I have the option to pass it as a prop directly. That should also be possible, right? It's just a prop at the end. Turns out, yes we can do that, but react docs suggests to use forwardRef for consistency.

  2. Attaching a ref to a DOM element is extremely easy. Add it as a value on ref attribute on the DOM element. For eg:

    <input ref={inputRef} />
    
  3. useImperativeHandle is used to expose certain methods of a child component to a parent component. It's mostly used along with forwardRef.

Let's dive a bit into code now and tinker around. I took the example from the official docs itself and extended it.

It's a simple example. A button to submit comment, an input for comment input, and a list of comments rendered in a div of fixed height.

Goal:

  1. Focus input on load.
  2. Read the comment input value.
  3. When the comment is submitted it should get added to the end of the list and it should get scrolled down to that comment.

Focusing input on load is an ancient thing now :).

Since I had anyway attached ref to input for focus, I used the same for reading the input value.

I want to attach 3 different methods on the ref.

  1. focus: that uses inputRef to focus.
  2. getComment: uses inputRef to get the value in the input
  3. scrollToBottom: uses commentListRef attached to div in which comment list is rendered.

So, I created a parent component <CommentDetail/> that renders both input and comment list and has these refs defined.

In the same component, we use the useImperativeHandle hook.

 const inputRef = React.useRef<HTMLInputElement | null>(null);
  const commentListRef = React.useRef<HTMLDivElement | null>(null);

  React.useImperativeHandle(
    ref,
    () => {
      return {
        focus: () => {
          inputRef.current?.focus();
        },
        scrollToBottom: () => {
            commentListRef.current?.scrollTo({
              behavior: "smooth",
              top: commentListRef.current.scrollHeight,
            });
        },
        getComment: () => {
          return inputRef.current?.value;
        },
      };
    },
    []
  );

Simple enough, right? The ref is received from props. commentListRef is passed and eventually gets attached to the div(DOM element) in which comment list is rendered. inputRef is passed and eventually gets attached to the comment input DOM element.

Bonus: If you try this code, you will observe there is always a little gap from the bottom when it gets scrolled to bottom after adding the comment. There is a small bug in our code! Will come back to this later!

Ok, hope it was clear till now!

Then, I was thinking why do we really need useImperativeHandle What is it doing? Basically storing the object in the ref.current right? So, if I do something like

 ref.current = () => {
      return {
        focus: () => {
          inputRef.current?.focus();
        },
        scrollToBottom: () => {
            commentListRef.current?.scrollTo({
              behavior: "smooth",
              top: commentListRef.current.scrollHeight,
            });

        },
        getComment: () => {
          return inputRef.current?.value;
        },
      };
    }

This too shall get the job done! And yes, it does work! But we broke the convention here. When using a ref, we always assume that current will be an object. In our case, it became a function. Let's refactor it a bit and return an object instead?

 ref.current =  {
        focus: () => {
          inputRef.current?.focus();
        },
        scrollToBottom: () => {

            commentListRef.current?.scrollTo({
              behavior: "smooth",
              top: commentListRef.current.scrollHeight,
            });

        },
        getComment: () => {
          return inputRef.current?.value;
        },
      }

And yes, this works as well. But now, whenever our component re-renders ref.current will be re-assigned. This can cause potential bugs in our code. And we know, refs are(and should be) retained by React between re-renders. While in our case, ref is getting changed between every re-render. Thus whatever we are doing is an anti-pattern and should avoid it.

Then, I thought, waiiiiit, I can wrap it do it inside useEffect. That way, if I optionally need to change ref when some deps change, I can do that as well!

Absolutely right! And congratulations 🎉 , because what you just did is, you excavated the internals of useImperativeHandle The hook internally does the same job, then why reinvent the wheel?

If the ref is an object with a current property, it assigns the value returned from the function passed as the second argument to useImperativeHandle to ref.current

function useImperativeHandle(ref, createHandle, deps) {
  // Only run this effect when the component mounts and when deps change
  useEffect(() => {
    if (ref !== null && typeof ref === 'object') {
      // If ref is an object with a 'current' property, assign the new handle to it
      ref.current = createHandle();
    }
  }, deps);
}

Ohh, and let's come back to the bug where it does not get scrolled to the bottom! The reason is the commentListRef.current.scrollHeight considers the stale value of the height which is the height of the div before the comment was added to the list. The solution is, we need to make sure that the height is calculated after the comment is added and rendered in the div. So, we wrap it inside setTimeout(...,0). This ensures that everything is rendered before calculating the height of the container, since whatever we do inside setTimeout will be executed only after call stack is cleared and event queue is empty. Here is the final working demo of the same.

Hope, I was able to add some value. Thank you for your time. If you think, there is something I have missed or maybe you find few corrections in my understanding , feel free to comment! Always, open to learn!

Thank you🙌. Peace✌️