A complex case to find and utilise position of a DOM element through React Refs and useCallback
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 bycreateRef()
, 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 argumentlastListSectionElement
to the callback functionlastListSectionRef
- 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 withnull
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 theref
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, sinceHasDoneInitialDrawRef.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.