React Callback Refs — a Complex Case

E.Y.
5 min readJun 8, 2020

--

A complex case to find and utilise position of a DOM element through React Refs and useCallback

Photo by Sereja Ris on Unsplash

This is a series of blogs documenting my learning journey on React Refs API…

In my last article, I discussed about accessing DOM/React element through its ref API. Today we are going to look at an advanced way to set the ref, and through a code example of autoscroll to the top of the last element of the list items.

So it turns out aside from setting refs directly, React also supports another way to set refs called “callback refs”, quoting the documentation: “gives more fine-grain control over when refs are set and unset.”

Instead of passing a ref attribute created by createRef(), you pass a function. The function receives the React component instance or HTML DOM element as its argument, which can be stored and accessed elsewhere.

The pattern — when reduced to the simplest pseudo code, looks like this:

const Simple = () => {
const ref = useCallback(node => {
if (node) node.focus() // a side effect!
}, [])
return <div ref={ref}>:)</div>
}

Just like how you use a normal ref by passing a value, you call the ref and pass a callback:

function NormalRef = ()=>{
const inputRef = React.useRef();

const onClick =()=> {
inputRef.current.focus();
}

return (
<>
<input ref={inputRef} />
<button onClick={onClick}>Click to Focus</button>
</>
);
}
==============================================
function
CallbackRef= ()=> {

const onClick =() => {
inputRef.focus();
}

return (
<>
<input ref={ref => {inputRef = ref; }} />
<button onClick={onClick}>Click to Focus</button>
</>
);
}

You can pass callback refs between components like you can with object refs that were created with React.createRef().

function TextInput(props) {
return (
<div>
<input ref={props.inputRef} /> </div>
);
}
class HigherComponent extends React.Component {
render() {
return (
<TextInput
inputRef={el => this.inputElement = el} />
);
}
}

The HigherComponent passes its ref callback as an inputRef prop to the TextInput, and the TextInput passes the same function as a special ref attribute to the <input>. As a result, this.inputElement in HigherComponent will be set to the DOM node corresponding to the <input> element in the TextInput.

So what’s so special about ref callback then?

Well, the difference is about the fine-grained control .

When using ref callback, instead of accessing the .current value directly, we now can manage the reference storage whenever we want. As per the documentation states: React will call the ref callback with the DOM element when the component mounts, and call it with null when it unmounts. Therefore, Refs are guaranteed to be up-to-date before componentDidMount or componentDidUpdate fires.

Another interesting use case is when we do something more complex with the DOM node/React element that has a ref attached on it, especially this element is dynamic — for example, a customised child component.

Let’s say we have a parent list wrapper that render a list of items, each is a <List/> component. And we want to set the window scroll position to the last list item (Why? Maybe it’s because after we click a button somewhere else we want to be returned to the last list item… the premise is not in discussion today)

import React, { FC, useCallback, useEffect, useRef, useState} from ‘react’;export const ParentList= (props) => { const {lists} = propsconst hasDoneInitialDrawRef = useRef(false);const lastListSectionRef =  useCallback((lastListSectionElement) => {
if (hasDoneInitialDrawRef.current && lastListSectionElement) {
window.scrollTo({ behaviour: 'smooth', top: lastListSectionElement.offsetTop });
}
hasDoneInitialDrawRef.current = true;
}, [hasDoneInitialDrawRef]);
.......
return (
<>
{lists.map((list, index) => (
<div
key={index}
ref={
index === lists.length - 1
? lastListSectionRef
: undefined

}
>
<List {...props} />

</div>
</>
))}

So here we can see it’s a very complex case:

  • after each list is mapped, in the ref attribute, the index is compared to the total length of the list, if it’s not the last item, then the ref points to undefined, but if it’s the last item,
  • then the node where the ref points to — the last <List/> element in this case, is passed as argument lastListSectionElement to the callback function lastListSectionRef
  • Inside the callback, the argument is been checked on offsetTop value where the window scroll to

Fair complex, right? Surely, you can also use the createRef API, but remember the normal ref only mount/unmount with the parent component, <ParentList/> in this case, it can’t respond to a Child (<List/>)being added and removed. If the Child shares the same life-span with the Parent, it is find. But it’s very often in React when the Child is dynamically added/removed from the Parent. But ref callback provides a safe reference between the Parent and Child by storing the Child Node into the Parent Ref property.

You may also notice that I didn’t mention about const hasDoneInitialDrawRef = useRef(false) I didn’t forget it — it is just the caveat of ref callback that I’m going to mention:

If the ref callback is defined as an inline function, it will get called twice during updates, first with null and then again with the DOM element. This is because a new instance of the function is created with each render, so React needs to clear the old ref and set up the new one. You can avoid this by defining the ref callback as a bound method on the class, but note that it shouldn’t matter in most cases.

This is because ref is first set after the first render(), but before componentDidMount(). So when you first render, there’s no data been mounted, the ref is pointed to null.

It’s not difficult to bypass this caveat though. Other than using bind method to replace inline callback function, we can just simply compare the ref with null. Something like:

if (typeof lastListSectionElement === "object" &&    !lastListSectionElement) {
//do the thing as planned
}

But in our case, we use an even fancier pattern — another Ref !

Noted that this ref is not been used to access DOM node, but to use to store a mutable value as an instance variable through the life span of the ParentList component. And we use it to skip the first render when ref callback is set to null:

  • Parent component renders, hasDoneInitialDrawRef is set to false
  • The first time lastListSectionRef is called, lastListSectionElement is set to null,
  • Since hasDoneInitialDrawRef.current evaluates to false, everything after && won’t be executed, now jumps to
  • HasDoneInitialDrawRef.current = true;
  • The second time lastListSectionRef is called, since HasDoneInitialDrawRef.current === true , the if statement is executed
  • Now the window is scrolled to the last item position

There’s a tiny detail of using useCallback hook instead of a normal callback function, and you can notice the function will only be called when HasDoneInitialDrawRef has changed. This provides additional performance benefit if you are confident that the Child component won’t get re-rendered during the lifespan of parent component.

--

--

Responses (1)