33
loading...
This website collects cookies to deliver better user experience
useRef
, but it lacks the lazy initializer functionality found in other hooks (useState
/ useReducer
/ useMemo
). useRef({ x: 0, y: 0 })
creates an object { x: 0, y: 0 }
on every render, but only uses it when mounting — it subsequent renders it’s thrown away. With useState
, we can replace the initial value with an initializer that’s only called on first render — useState(() => ({ x: 0, y: 0 }))
(I’ve explored this and other useState
features in my older post). Creating functions is very cheap in modern JS runtimes, so we skip allocating memory and building the object for a slight performance boost.useRef
is your primary tool for avoiding useless re-renders. In this post, I’ll show you four ways to support lazy initializer in useRef
:useEffect
useRef
initializer that works like useState
initializer.useRef
on top of useState
(almost zero code!)useRef
that only computes the value when you read .current
const touch = useRef({ x: 0, y: 0 });
const onTouchMove = e => {
touch.current = {
x: e.touches[0].clientX,
y: e.touches[0].clientY,
};
};
useRef(9)
, since those are cheap to create, too.useMemo
does not guarantee it. We don’t really want to reassign current
, so a RefObject
API is not needed:// Would be nice
const observer = useStableMemo(() => new IntersectionObserver(cb), []);
// Why write observer.current if you never swap an observer?
const rootRef = useRef(e => observer.observe(e)).current;
useRef()
with a mount effect:const ref = useRef();
useEffect(() => {
ref.current = initialValue;
}, []);
.current
value is not accessible before the effect — in the first render phase, in DOM refs, useLayoutEffect
, and even in some other useEffect
s (inside child components and ones scheduled before the init effect) — try it yourself in a codepen. If the whole useRef
+ useEffect
construction is written inline in a component, you at least see that the initialization is delayed. Wrapping it into a custom hook increases the chances of a misuse:const observer = useLazyRef(() => new IntersectionObserver(...));
// spot the bug
useLayoutEffect(() => {
observer.current.observe(node);
}, []);
.current
is awkwardly pushed into effects, complicating your code:const [width, setWidth] = useState(0);
const node = useRef();
const observer = useLazyRef(() =>
new ResizeObserver(([e]) => setWidth(e.borderBoxSize.width)));
useEffect(() => {
observer.current.observe(node.current)
}, []);
return <div ref={node} data-width={width} {...props} />
useEffect
with useLayoutEffect
does not help much — a bunch of places that can’t access the current
still exists (first render, DOM refs, child useLayoutEffect
s), and now the initialization blocks the paint. As we’ll see now, better ways to initialize early exist.useEffect
approach works OK if you only need .current
later — in other effects, timeouts or event handlers (and you’re 100% sure those won’t fire during the first paint). It’s my least favorite approach, because the other ones work better and avoid the “pre-initialization gap”..current
value to be available at all times, but without re-creation on every render (a lot like useState
/ useMemo
), we can just build a custom hook over bare useRef
ourselves (see codepen):// none is a special value used to detect an uninitialized ref
const none = {};
function useLazyRef(init) {
// not initialized yet
const ref = useRef(none);
// if it's not initialized (1st render)
if (ref.current === none) {
// we initialize it
ref.current = init();
}
// new we return the initialized ref
return ref;
}
useLazyRef
hooks: it works anywhere — inside render, in effects and layout effects, in listeners, with no chance of misuse, and is similar to the built-in useState
and useMemo
. To turn it into a readonly ref / stable memo, just return ref.current
— it’s already initialized before useLazyRef
returns.Note that using null
as the un-initialized value breaks if init()
returns null
, and setting ref.current = null
triggers an accidental re-initialization on next render. Symbol
works well and might be more convenient for debugging.
observers
, because they’re safe to use from DOM refs:const [width, setWidth] = useState(0);
const observer = useLazyRef(() =>
new ResizeObserver(([e]) => setWidth(e.borderBoxSize.width))).current;
const nodeRef = useRef((e) => observer.observe(e)).current;
return <div ref={nodeRef} data-width={width} {...props} />
useRef
over other hooks.useState
has the lazy initializer feature we want, why not just use it instead of writing custom code (codepen)?const ref = useState(() => ({ current: init() }))[0];
useState
with a lazy initializer that mimics the shape of a RefObject, and throw away the update handle because we’ll never use it — ref identity must be stable. For readonly ref / stable-memo we can skip the { current }
trick and just useState(init)[0]
. Storing a mutable object in useState
is not the most orthodox thing to do, but it works pretty well here. I imagine that at some point future react might choose to rebuild the current useState
by re-initializing and re-applying all the updates (e.g. for HMR), but I haven’t heard of such plans, and this will break a lot of stuff.useState
can also be done with useReducer
, but it’s slightly more complicated:useReducer(
// any reducer works, it never runs anyways
v => v,
// () => {} and () => 9 work just as well
() => ({ current: init() }))[0];
// And here's the stable memo:
useReducer(v => v, init)[0];
useMemo
, doesn’t work well. useMemo(() => ({ current: init() }), [])
currently returns a stable object, but React docs warn against relying on this, since a future React version might re-initialize the value when it feels like it. If you’re OK with that, you didn’t need ref
in the first place.useImperativeHandle
is not recommended, too — it has something to do with refs, but its implemented to set the value in a layout effect, similar to the worst one of our async
options. Also, ituseState
allows you to build a lazy ref with almost zero code, at a minor risk of breaking in a future react version. Choosing between this and a DIY lazy ref is up to you, they work the same..current
?const none = {};
function useJitRef(init) {
const value = useRef(none);
const ref = useLazyRef(() => ({
get current() {
if (value.current === none) {
value.current = init();
}
return value.current;
},
set current(v) {
value.current = v;
}
}));
return ref;
}
current
goes through the get()
, computing the value on first read and returning the cached value later.current
updates the value instantly and removes the need to initialize.useLazyRef
itself to preserve the builtin useRef
guarantee of stable identity and avoid extra object creation.const none = {};
function useMemoGet(init) {
const value = useRef(none);
return useCallback(() => {
if (value.current === none) {
value.current = init();
}
return value.current;
}, []);
}
useLazyRef
. If the initializer is really heavy, and you use the value conditionally, and you often end up not needing it, sure, it’s a good fit. Honestly, I have yet to see a use case that fits these conditions.requestIdleCallback(() => ref.current)
ref.current = () => el.clientWidth
getWidth = useMemoGet(() => el.clientWidth)
you can mark the cached value as stale with getWidth.invalidate()
on content change.useState
is an alternative implementation of ) for creating lazy useRef. They all have different characteristics that make them useful for different problems:useEffect
— not recommended because it’s easy to hit un-initialized .current
.useRef
works well, but blocks first render. Good enough for most cases.useState
‘s initializer, but hiding the update handle. Least code, but a chance of breaking in future react versions.useRef
that only computes the value when you read .current
— complicated, but flexible and never computes values you don’t use.