We've tried to delay addEventListener
until after paint with useEffect
, but the state update in useLayoutEffect
forces it to happen before paint (see sandbox):
useLayoutEffect
is not the only place where updating forces an early effect flush — host refs (<div ref={HERE}>
), requestAnimationFrame
loops, and microtasks scheduled from uLE trigger the same behavior.
Fine, this is not the end of the world — under some circumstances, your render flow is less optimal than it could be, who cares. Still, it's useful to know the limitations of your tool. Here are 4 practical lessons to learn:
Even if you know the catch, it's very hard to make sure some useEffect
is not affected by useLayoutEffect
state update:
useLayoutEffect
. But are you sure none of the custom hooks (e.g. usePopper
) do that?useContext
or a parent re-render.useEffect
, and a memo()
. But effects from an update are flushed globally, so a pre-paint update in other components still flushes child effects.With a lot of discipline you probably can have a codebase with no state updates in useLayoutEffect
, but that's superhuman. The best advice is not to rely on useEffect
to fire after paint, just like useMemo
does not guarantee 100% stable reference. If you want the user to see something painted for one frame, useEffect
is not the way to do it — try double requestAnimationFrame
or do the postMessage trick yourself.
Conversely, suppose you don't listen to the good advice from React team and update DOM in useEffect
. You test it, and, aha!, no flickering. Bad news — maybe it's the result of a state update before paint. Move some code around, and it will flicker.
Following useEffect
vs useLayoutEffect
guidelines to the letter, we could split one logical side-effect into a layout effect to update the DOM, and a "delayed" effect, like we've done in our ResponsiveInput
example:
// DOM update = layout effect
useLayoutEffect(() => setWidth(el.current.offsetWidth), []);
// subscription = lazy logic
useEffect(() => {
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, []);
However, as we now know, this does nothing — both effects are flushed before render. Besides, the separation is sloppy — if we pretend useEffect
does fire after paint, are you 100% sure the element won't resize between the effects? I'm not. Leaving all size-tracking logic in a single layoutEffect
here is safer, cleaner, has the same amount of pre-paint work, and gives React one less effect to manage — pure win:
useLayoutEffect(() => {
setWidth(el.current.offsetWidth);
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, []);
Good advice, but easier said than done — useEffect
is a worse place to update state, because flickering is poor UX, and UX is more important than performance. Updating state during render looks dangerous.
Sometimes state can be safely replaced with useRef. Updating a ref doen't trigger an update, and the effect can run as intended. I happen to have a post exploring some of these cases.
If you can, try to come up with a state model that doesn't rely on effects, but I don't know how to invent "good" state models on command.
If you find particular useLayoutEffect
causing trouble, consider bypassing state update and mutating DOM directly. That way, react doesn't schedule an update, and needn't flush effects eagerly. We could try:
const clearRef = useRef();
const measure = () => {
// No worries react, I'll handle it:
clearRef.current.display = el.current.offsetWidth > 200 ? null : none;
};
useLayoutEffect(() => measure(), []);
useEffect(() => {
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, []);
return (
<label>
<input {...props} ref={el} />
<button ref={clearRef} onClick={onClear}>clear</button>
</label>
);
I've explored this technique in my older post on avoiding useState
, and we just got one more reason to skip react updates. Still, manually managing DOM updates is complicated and error-prone, so reserve this trick for performance-critical situations — very hot components or super-heavy useEffects.
Today we've discovered that useEffect
sometimes executes before paint. A frequent cause is updating state in useLayoutEffect
— it requests a re-render before paint, and the effect must run before that re-render. This also happens when updating state from RAFs or microtasks. What this means for us:
useLayoutEffect
is not good for app performance. Try not to do that, but sometimes there is no good alternative.useEffect
to fire after paint.useEffect
will cause a visible flicker — maybe you don't see it because of a layout effect updating state.useLayoutEffect
into useEffect
for performance makes no sense if you set state in the layout effect part.