React refs are used when you want to update a value between renders but do not want the update to trigger rerendering. Here's one of the examples: useIsFirstRender
returns true only if it is on the first render.
function useIsFirstRender(): boolean {
const isFirstRender = useRef(true);
useEffect(() => {
isFirstRender.current = false;
return () => {
isFirstRender.current = true;
}
}, []);
return isFirstRender.current;
}
Making isFirstRender
a ref object would be better than making it a state, because modifying a state could potentially trigger other side effects unnecessarily during rerenders.
Now let's take a look at another example of useRef
. We need to customize a hook useFocus
. useFocus
returns a ref object to be assigned to an input element and a boolean isFocus
indicating if the input is currently being focused.
function useFocus<T extends HTMLElement>(): [Ref<T>, boolean];
function App() {
const [ref, isFocused] = useFocus<HTMLInputElement>()
return <div>
<input ref={ref}/>
{isFocused && <p>focused</p>}
</div>
}
At the first glance, you might be thinking to use a useEffect
block, so we can add focus
& blur
event listeners from the input in the useEffect
, and remove the listeners in a cleanup function. However, this would not work out well in the case that we have two inputs in the same component and the ref
could potentially be switched from one input the other input. Let's visualise what I mean:
export function App() {
const [buttonLabel, setButtonLabel] = useState(0);
const toggle = () => setButtonLabel(buttonLabel ? 0 : 1);
const [ref, isFocused] = useFocus<HTMLInputElement>()
return <div>
<input ref={buttonLabel ? null: ref}/>
<input ref={buttonLabel ? ref : null} />
<button onClick={toggle}>Toggle input</button>
{isFocused && <p>focused</p>}
</div>
}
As you can see, when the ref changes, we need a way to force the useEffect
cleanup function to run, otherwise it will cause the problem where the event listeners retain in the previous input.
Introducing Callback Refs
Here is when ref callbacks become useful.
<div ref={(node) => console.log(node)} />
By default ref callbacks are invoked when you pass in a new ref callback. Basically this happens in the following scenarios:
- The component mounts
- The component unmounts
- The component rerenders
Now back to our example, here's the solution with ref callbacks:
const refCallback = useCallback((node: T) => {
if(inputRef.current) {
inputRef.current.removeEventListener('focus', handleFocus);
inputRef.current.removeEventListener('blur', handleBlur);
}
if(node) {
node.addEventListener('focus', handleFocus);
node.addEventListener('blur', handleBlur);
}
inputRef.current = node;
}, [])
We wrap the ref callback with useCallback
to avoid the callback being triggered every time the component rerenders. E.g, when the user focuses/unfocuses on the input. Now when the ref changes, node changes from the old input to undefined, then takes the new input. The logic sequence goes:
- Remove the listeners from the old input
-
inputRef.current
becomes undefiend - Attach the listeners to the new input
- Assign the new input to
inputRef.current
Here is the full solution:
import React, { Ref, useState, useRef, useCallback } from 'react'
export function useFocus<T extends HTMLElement>(): [Ref<T>, boolean] {
const [isFocused, setFocused] = useState(false);
const input = useRef<T>();
const handleOnFocus = useCallback(() => setFocused(true), []);
const handleOnBlur = useCallback(() => setFocused(false), []);
const inpurRefCallback = useCallback((node: T) => {
if (input.current) {
// cleanup listeners on previous ref
input.current.removeEventListener('focus', handleOnFocus);
input.current.removeEventListener('blur', handleOnBlur);
}
if (node) {
// Add listeners on new ref
node.addEventListener('focus', handleOnFocus);
node.addEventListener('blur', handleOnBlur);
}
// save new ref
input.current = node;
}, [])
return [inpurRefCallback, isFocused]
}
Ref callbacks would be a perfect usage in the custom hooks like useFocus
and useHover
etc. It gives you more control over your implementation when the node
changes.
I hope you have learnt something from this post. Feel free to post any questions in the comment section below.
Bonus
There's also a solution if you really want to use useEffect
to solve the problem in the example. Here's the code:
export function useFocus<T extends HTMLElement>(): [Ref<T | undefined>, boolean] {
const [isFocused, setFocused] = useState(false)
const ref = useRef<T>()
useEffect(() => {
const currentElement = ref.current
if (!currentElement)
return
// initialize the focus state when currentElement changes.
setFocused(document.activeElement === currentElement)
const onFocus = () => setFocused(true)
const onBlur = () => setFocused(false)
currentElement.addEventListener('focus', onFocus)
currentElement.addEventListener('blur', onBlur)
return () => {
currentElement.removeEventListener('focus', onFocus)
currentElement.removeEventListener('blur', onBlur)
}
}, [ref.current]) // now we can pass a dependency array to get much better performance.
return [ref, isFocused]
}
However, I personally would not recommend this approach because when you specify ref.current
in the useEffect's dependency array, how side effect updates immedicately becomes unintuitive. For example, with the above code on the first render:
- useEffect gets triggered, and it double sets focused to false
- attach the event listeners
When the user focuses on the input for the first time:
- useEffect gets triggered again since
ref.current
has changed from undefined to the input, and it double sets focused to true
When the user focuses on the input after the first time:
- useEffect won't be triggered again
The problem is that the value of ref in the deps array is not in sync with the one in the useEffect's body.
Top comments (0)