I was working on a React project that read scroll position and dynamic component height to render a scroll indicator. The measurements were wrong on the first paint, so I went back through the React component lifecycle to work out why.
Component lifecycle in React js has three phases:
React uses the virtual DOM to track changes and only touches the real DOM when it has to. The harder part is knowing when to run your own code inside each phase.
useLayoutEffect runs before the browser paintsuseEffect covers most cases: tracking changes, and handling mount and unmount behavior. useLayoutEffect runs its logic after React has worked on the virtual DOM but before the browser paints anything on the screen. That makes it the right hook for layout calculations that must land before the user sees a frame.
In a class component these phases are named methods: componentDidMount, componentDidUpdate, and componentWillUnmount. In a functional component, both hooks cover all three phases, and the dependency array decides which phase you are in.
useEffect is asynchronous, useLayoutEffect is synchronousThe difference that matters is timing:
useEffect is asynchronous: it runs after the browser has painted the DOM. The user can see updates on the screen before the code inside useEffect executes. Use it for non-blocking work like API calls or logging.useLayoutEffect is synchronous: it blocks the browser from painting until its code runs. Use it for measuring DOM elements or adjusting layout, because everything is in place before the user sees it.So useLayoutEffect writes to the DOM before the first visible frame, and useEffect writes to it after. For a component that depends on precise measurements, that is the difference between a correct first paint and a visible jump.
This component registers both hooks three times each: with an empty dependency array, with no array, and with [count].
const Component = () => {
const [count, setCount] = useState(0);
useEffect(() => {
console.log("UseEffect with empty dependency array");
}, []);
useLayoutEffect(() => {
console.log("UseLayoutEffect with empty dependency array");
}, []);
useEffect(() => {
console.log("UseEffect with no dependency");
});
useLayoutEffect(() => {
console.log("UseLayoutEffect with no dependency");
});
useEffect(() => {
console.log("UseEffect with count: " + count);
}, [count]);
useLayoutEffect(() => {
console.log("UseLayoutEffect with count: " + count);
}, [count]);
console.log("React component rendered");
return (
<div>
<button
onClick={() => {
console.log("Increase Button Clicked");
setCount((c) => c + 1);
}}
>
Increase
</button>
<div>count: {count}</div>
</div>
);
};
The widget below runs the same component, so you can compare the console output against what you expect.
Here is the breakdown:
"React component rendered".useEffect and useLayoutEffect hooks with empty dependency arrays fire once, logging their respective messages.useEffect and useLayoutEffect with no dependency array fire every time the component renders (initial and updates).[count] Dependency:
useEffect and useLayoutEffect hooks with [count] fire only when count changes.setCount, causing a state update. This:
"Increase Button Clicked" and "React component rendered".useEffect with an empty array runs once after the component mounts.useEffect without a dependency array runs after every render.useLayoutEffect behaves similarly but fires earlier in the update cycle.[count]) ensures the hook runs only when those values change.Next time a measurement reads wrong on first paint, check which of the two hooks the code is using before you change the measurement.
Occasional notes on software, tools, and things I learn. No spam.
Unsubscribe anytime.