useEffect
should run after paint to prevent blocking the update. But did you know it’s not really guaranteed to fire after paint? Updating state in useLayoutEffect
makes every useEffect
from the same render run before paint, effectively turning them into layout effects. Confusing? Let me explain.
In a normal flow, react updates go like this:
- React stuff: render virtual DOM, schedule effects, update real DOM
- Call
useLayoutEffect
- React releases control, browser paints the new DOM
- Call
useEffect
React docs don’t say when, exactly, useEffect fires — it happens, quote, after layout and paint, during a deferred event. I always assumed it was a setTimeout(effect, 3)
, but it appears to use a MessageChannel
trick, which is neat.
There is, however, a more interesting passage in the docs:
Although useEffect is deferred until after the browser has painted, it’s guaranteed to fire before any new renders. React will always flush a previous render’s effects before starting a new update.
This is a good guarantee — you can be sure no updates are missed. But it also implies that sometimes the effect fires before paint. If a) effects are flushed before a new update starts, and b) an update can start before paint, e.g. when triggered from useLayoutEffect
, then the effect must be flushed before that update, which is before paint. Here’s a timeline:
- React update 1: render virtual DOM, schedule effects, update DOM
- Call
useLayoutEffect
- Update state, schedule re-render
- Call
useEffect
- React update 2
- Call
useLayoutEffect
from update 2 - React releases control, browser paints the new DOM
- Call
useEffect
from update 2
This is not a very rare situation — you can’t really update state in useEffect
, because updating state updates the DOM, and doing so after paint leaves the user with one stale frame, resulting in noticeable flickering.
For example, let’s build a responsive input (like a fake CSS container query) that only renders the clear button if the input is wider than 300px
. We need real DOM to measure the input, so we need some effect. We also don’t want the icon to appear / disappear after one frame, so the initial measurement goes into useLayoutEffect
:
const ResponsiveInput = ({ onClear, ...props }) => {
const el = useRef();
const [w, setW] = useState(0);
const measure = () => setW(el.current.offsetWidth);
useLayoutEffect(() => measure(), []);
useEffect(() => {
// don't take this too seriously, say it's a ResizeObserver
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, []);
return (
<label>
<input {...props} ref={el} />
{w > 200 &&
<button onClick={onClear}>clear</button>}
</label>
);
};
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):
Now, 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:
Don’t rely on useEffect to fire after update
Even if you know the catch, it’s very hard to make sure some useEffect
is not affected by useLayoutEffect
state update:
- My components doesn’t
useLayoutEffect
. But are you sure none of the custom hooks it uses do that? - My components only uses built-in React hooks. But a uLE state update up the tree can leak through
useContext
or a parent re-render. - My components only has
useEffect
, and amemo()
. But a uLE state update in the parent still appears to flush 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.
Don’t waste your time splitting layout effects
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);
}, []);
Don’t update state in useLayoutEffect
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. 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.
Bypass state update
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. What this means for us:
- Updating state in
useLayoutEffect
is not good for app performance. Try not to do that, but sometimes there is no good alternative. - Don’t rely on
useEffect
to fire after paint. - Updating DOM from
useEffect
will cause a visible flicker — maybe you don’t see it because of a layout effect updating state. - Extracting a part of
useLayoutEffect
intouseEffect
for performance makes no sense if you set state in the layout effect part. - One more reason to mutate the DOM from uLE manually in performance-critical cases.
Top comments (0)