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:

  1. React stuff: render virtual DOM, schedule effects, update real DOM
  2. Call useLayoutEffect
  3. React releases control, browser paints the new DOM
  4. 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:

  1. React update 1: render virtual DOM, schedule effects, update DOM
  2. Call useLayoutEffect
  3. Update state, schedule re-render
  4. Call useEffect
  5. React update 2
  6. Call useLayoutEffect from update 2
  7. React releases control, browser paints the new DOM
  8. 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(() => { 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):

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:

  1. My component doesn’t useLayoutEffect. But are you sure none of the custom hooks (e.g. usePopper) do that?
  2. My component only uses built-in React hooks. But a uLE state update up the tree can leak through useContext or a parent re-render.
  3. My component only has 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.

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:


useLayoutEffect(() => setWidth(el.current.offsetWidth), []); 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 = () => { 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:

  1. Updating state in useLayoutEffect is not good for app performance. Try not to do that, but sometimes there is no good alternative.
  2. Don’t rely on useEffect to fire after paint.
  3. Updating DOM from useEffect will cause a visible flicker — maybe you don’t see it because of a layout effect updating state.
  4. Extracting a part of useLayoutEffect into useEffect for performance makes no sense if you set state in the layout effect part.
  5. One more reason to mutate the DOM from uLE manually in performance-critical cases.

首页 - Wiki
Copyright © 2011-2024 iteam. Current version is 2.137.1. UTC+08:00, 2024-11-22 19:54
浙ICP备14020137号-1 $访客地图$