React refs appear to be a very simple feature. You pass a special prop to a DOM component, and you can access the current DOM node for that component in your JS. This is one of those great APIs that work just the way you'd expect, so you don't even think about how, exactly, it happens. Along my descent into React internals I started noticing that there was more to the ref API than I always thought. I dug deeper, and in this post I'll share my findings with you and provide a few neat ref tricks to help you write better code.
How react refs are set
To get the basics out of the way, ref
is set to the DOM node when it's mounted, and set to null when the DOM node is removed. No surprises this far.
One thing to note here is that a ref is, strictly speaking, never updated. If a DOM node is replaced by some other node (say, its DOM tag or key
changes), the ref is unset, and then set to a new node. (You may think I'm being picky here, but it's goint to prove useful in a minute.) The following code would log null -> <div>
on rerender (also see sandbox):
const ref = useCallback((e) => console.log("ref", e), []);
const [iter, rerender] = useState(0);
return (
<div ref={ref} key={iter} onClick={() => rerender(iter + 1)}>
click to remount
</div>
);
The part I was not aware of is that the identity of ref
prop also forces it to update. When a ref
prop is added, it's set to DOM node. When a ref
prop is removed, the old ref is set to null. Here, again, the ref is unset, than set again. This means that if you pass an inline arrow as a ref
, it'll go through unset / set cycle on every render (sandbox):
const rerender = useState()[1];
return (
<div ref={(e) => console.log("ref", e)} onClick={() => rerender({})}>
click to remount
</div>
);
So, why does it work that way? In short, it allows you to attach refs
conditionally and even swap them between components, as in
<ul>
{items.map((e, i) => (
<div ref={i === items.length - 1 ? lastRef : null}>{e.text}<li>
))}
</ul>
So far we've leant that refs are set node when the DOM mounts or when the ref prop is added, and unset when the DOM unmounts or the ref prop is removed. As far as I'm concerned, nothing else causes a ref to update. A changing ref always goes through null
. If you're fluent in hooks, it works as if the code for DOM components had:
useLayoutEffect(() => {
ref.current = domNode;
return () => ref.current = null;
}, [ref]);
Ref update ordering
Another important principle specifies the order in which refs are set and unset. The part we rely on the most is that the ref is always set before useLayoutEffect / componentDidMount / Update
for the corresponing DOM update is called. This, in turn, means that useEffect
and parent useLayoutEffect
are also called after the ref is set.
In a single render, all the ref unsets happen before any set — otherwise, you'd get a chance to unset a ref that's already been set during this render.
Next, useLayoutEffect
cleanup during re-rendering runs right between ref unset and set, meaning that ref.current
is always null
there. To be honest, I'm not sure why it works this way, as it's a prime way to shoot yourself in the foot, but this seems to be the case for all react versions with hooks. See for yourself.
In contrast, componentWillUnmount
and unmount useLayoutEffect()
cleanup are called before the ref is unset, so that you get a chance to cleanup anything you've attached to the DOM node, as you can see in a sandbox.
Here's a chart that summarizes all this timing:
Now I feel like we're getting somewhere in our understanding of refs
— but does it have any practical value? Read on!
Don't use ref.current in useLayoutEffect cleanup
First off — using dynamic refs in useLayoutEffect
cleanup callback is unsafe since you can get an unexpected null
. Store ref.current
in a closure variable and use that instead:
useLayoutEffect(() => {
ref.current.addEventListener('click', onClick);
return () => ref.current.removeEventListener('click', onClick);
}. [onClick]);
// becomes...
useLayoutEffect(() => {
const node = ref.current
node.addEventListener('click', onClick);
return () => node.removeEventListener('click', onClick);
}. [onClick]);
Granted, this only works for arrow refs or when you attach a ref conditionaly, but better safe than sorry, right? At least it's good to know exactly why this breaks and not wrap everything in if (ref.current)
just in case.
You can side effect in ref callback
A cool and useful implication of this is that you can safely put expensive side effects in a callback ref (or a set current()
of a ref object) as long as ref identity does not change. For example, a typical DOM measuring logic:
const el = useRef();
const [size, setSize] = useState();
useLayoutEffect(() => {
setSize(el.current.getBoundingClientRect());
}, []);
return <div ref={el}>{children}</div>;
Becomes...
const [size, setSize] = useState();
const measureRef = useCallback(node => {
setSize(node.getBoundingClientRect())
}, []);
return <div ref={measureRef}>{children}</div>;
Which is slightly cleaner and has one variable less.
Ref arrows
There's a subtle difference between having an arrow as your ref
prop and a ref object or a stable callback — the arrow has a new identity on every render, forcing the ref to go through an update cycle null
. This is normally not too bad, but good to know.
// this does node -> null -> node on every render
<div ref={e => this.node = e} />
// this doesn't
<div ref={useCallback(e => this.node = e, [])} />
// neither does this
setRef = e => this.node = e;
<div ref={this.setRef} />
// this is fine, too
const ref = useRef();
<div ref={ref} />
setState can be a callback ref
If you want setting ref to trigger a rerender, you can just pass setState
updater as a ref prop. This code will give children
access to root DOM node, and will not fall into infinite re-rendering or anything:
const [root, setRoot] = useState();
return (
<div ref={setRoot}>
<RootContext.Provider value={useMemo(() => root, [root]))}>
{root ? children : null}
</RootContext.Provider>
</div>
);
Merging refs is hard
Finally, if you implement some kind of ref merging (when you have a forwardRef
/ innerRef
, but also need te DOM node for yourself), you should take care to preserve the guarantees native ref provides, because they are there for a reason. Almost all ref merging mechanisms I've seen in the wild miss some points we've discussed today. The web is full of tutorials that offer you subtly broken solutions. A library with 22K stars fails to do it right. Here's my best shot at this problem, and I'm still not sure it ticks all the boxes:
function useExternRef(externRef) {
const stableRef = useRef();
return useMemo(() => ({
get current() {
return stableRef.current;
},
set current(el) {
stableRef.current = el;
setRef(el, externRef);
},
}), [externRef]);
}
Knowing this, I wouldn't be comfortable with any advanced ref patterns (conditional refs / side effects) on non-DOM components.
Now on to a brief recap:
- Refs are set when the DOM is mounted or a
ref
prop is added. - Refs are unset when the DOM is removed or a
ref
prop is removed. - Refs are always unset, then set, and never switch between two nodes directly.
- It's safe to use
refs
conditionaly and even move them between nodes. - The order in which refs are set and unset relative to
useLayoutEffect
and lifecycle hooks is well defined. - Callback ref can be a side effect or a
useState
setter - Useing
ref.current
inuseLayoutEffect
cleanup is unsafe. - Merging refs is hard, so take care yourself and don't trust the
ref
prop in components you didn't write.
Phew. Now I think we really know everything about react refs.
Top comments (0)