Avoiding useEffect with callback refs
— ReactJs, JavaScript, TypeScript — 3 min read
Note: This article assumes a basic understanding of what refs are in React.
Even though refs are mutable containers where we can theoretically store arbitrary values, they are most often used to get access to a DOM node:
1const ref = React.useRef(null)2
3return <input ref={ref} defaultValue="Hello world" />
ref
is a reserved property on build-in primitives, where React will store the DOM node after it was rendered. It will be set back to null when the component is unmounted.
Interacting with refs
For most interactions, you don't need to access the underlying DOM node, because React will handle updates for us automatically. A good example where you might need a ref is focus management.
There's a good RFC from Devon Govett that proposes adding FocusManagement to react-dom, but right now, there is nothing in React that will help us with that.
Focus with an effect
So how would you, right now, focus an input element after it rendered? (I know autofocus exists, this is an example. If this bothers you, imagine you'd want to animate the node instead.)
Well, most code I've seen tries to do this:
1const ref = React.useRef(null)2
3React.useEffect(() => {4 ref.current?.focus()5}, [])6
7return <input ref={ref} defaultValue="Hello world" />
This is mostly fine and doesn't violate any rules. The empty dependency array is okay because the only thing used inside is the ref, which is stable. The linter won't complain about adding it to the dependency array, and the ref is also not read during render (which might be troublesome with concurrent React features).
The effect will run once "on mount" (twice in strict mode). By that time, React has already populated the ref with the DOM node, so we can focus it.
Yet this is not the best way to do it and does have some caveats in some more advanced situations.
Specifically, it assumes that the ref is "filled" when the effect runs. If it's not available, e.g. because you pass the ref to a custom component which will defer the rendering or only show the input after some other user interaction, the content of the ref will still be null when the effect runs and nothing will be focussed:
1function App() {2 const ref = React.useRef(null)3
4 React.useEffect(() => {5 // 🚨 ref.current is always null when this runs6 ref.current?.focus()7 }, [])8
9 return <Form ref={ref} />10}11
12const Form = React.forwardRef((props, ref) => {13 const [show, setShow] = React.useState(false)14
15 return (16 <form>17 <button type="button" onClick={() => setShow(true)}>18 show19 </button>20 // 🧐 ref is attached to the input, but it's conditionally rendered21 // so it won't be filled when the above effect runs22 {show && <input ref={ref} />}23 </form>24 )25})
Here is what happens:
- Form renders.
- input is not rendered, ref is still null.
- effect runs, does nothing.
- input is shown, ref will be filled, but will not be focussed because effect won't run again.
The problem is that the effect is "bound" to the render function of the Form, while we actually want to express: "Focus the input when the input is rendered", not "when the form mounts".
Callback refs
This is where callback refs come into play. If you've ever looked at the type declarations for refs, we can see that we can not only pass a ref object into it, but also a function:
1type Ref<T> = RefCallback<T> | RefObject<T> | null
Conceptually, I like to think about refs on React elements as functions that are called after the component has rendered. This function gets the rendered DOM node passed as argument. If the React element unmounts, it will be called once more with null.
Passing a ref from useRef (a RefObject) to a React element is therefore just syntactic sugar for:
1<input2 ref={(node) => {3 ref.current = node;4 }}5 defaultValue="Hello world"6/>
Let me emphasize this once more:
And those functions run after rendering, where it is totally fine to execute side effects. Maybe it would have been better if ref would just be called onAfterRender or something.
With that knowledge, what stops us from focussing the input right inside the callback ref, where we have direct access to the node?
1<input2 ref={(node) => {3 node?.focus()4 }}5 defaultValue="Hello world"6/>
Well, a tiny detail does: React will run this function after every render. So unless we are fine with focussing our input that often (which we are likely not), we have to tell React to only run this when we want to.
useCallback to the rescue
Luckily, React uses referential stability to check if the callback ref should be run or not. That means if we pass the same ref(erence, pun intended) to it, execution will be skipped.
And that is where useCallback comes in, because that is how we ensure a function is not needlessly created. Maybe that's why they are called callback-refs - because you have to wrap them in useCallback all the time. 😂
Here's the final solution:
1const ref = React.useCallback((node) => {2 node?.focus()3}, [])4
5return <input ref={ref} defaultValue="Hello world" />
Comparing this to the initial version, it's less code and only uses one hook instead of two. Also, it will work in all situations because the callback ref is bound to the lifecycle of the DOM node, not of the component that mounts it. Further, it will not execute twice in strict mode (when running in the development environment), which seems to be important to many.
And as shown in this hidden gem in the (old) React docs, you can use it to run any sort of side effects, e.g. call setState in it. I'll just leave the example here because it's actually pretty good:
1function MeasureExample() {2 const [height, setHeight] = React.useState(0)3
4 const measuredRef = React.useCallback(node => {5 if (node !== null) {6 setHeight(node.getBoundingClientRect().height)7 }8 }, [])9
10 return (11 <>12 <h1 ref={measuredRef}>Hello, world</h1>13 <h2>The above header is {Math.round(height)}px tall</h2>14 </>15 )16}
So please, if you need to interact with DOM nodes directly after they rendered, try not to jump to useRef + useEffect directly, but consider using callback refs instead.
That's it for today. Feel free to reach out to me on twitter if you have any questions, or just leave a comment below. ⬇️