44
useEffect sometimes fires before paint
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:
useLayoutEffect
useEffect
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:
useLayoutEffect
useEffect
useLayoutEffect
from update 2useEffect
from update 2This 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:
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 it uses do that?useContext
or a parent re-render.useEffect
, and a memo()
. 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.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. 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. 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.44