Replacing 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.
The 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".
If we want the .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;
}
This implementation is a good default for custom 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 ifinit()
returnsnull
, and settingref.current = null
triggers an accidental re-initialization on next render.Symbol
works well and might be more convenient for debugging.
This is the most convenient approach for storing 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} />
The only downside is that the initializer runs even if we never read the value. I'll show you how to avoid this, but first let's see how we can (and can't) build this flavor of lazy useRef
over other hooks.
If 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];
We 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.
As usual, anything doable with 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];
The most obvious base hook, 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, it
So, useState
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.
I'd argue that what we've discussed so far isn't really lazy — sure, you avoid useless job on re-render, but you still eagerly compute the initial value on first render. What if we only computed the value on demand, when someone reads .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;
}
Tricky! See codepen, and let me break it down for you:
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.For readonly ref / stable memo, try the simpler getter function approach suggested in react docs:
const none = {};
function useMemoGet(init) {
const value = useRef(none);
return useCallback(() => {
if (value.current === none) {
value.current = init();
}
return value.current;
}, []);
}
Is it worth the trouble? Maybe, maybe not. The code is more complicated than the eager 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.
This is a very interesting and flexible technique that supports many variations:
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.We've covered 4 good base techniques (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.Hope you find this useful! If you want to learn more about react, check out my other posts.