Layout effects and timing
Learn when React runs Effects, measure the page before the browser paints with useLayoutEffect, and see where useInsertionEffect and servers fit in.
- 01Say when each Effect runsPut render, commit, layout effects, paint and Effects in order, and say what changes when a click caused the render.
- 02Measure before the paintUse
useLayoutEffectto measure an element and render again before the browser paints, so nothing jumps on the screen. - 03Pick the right hookKeep most Effects in
useEffect, useuseLayoutEffectonly to fix what the person would see, and leaveuseInsertionEffectto CSS-in-JS libraries.
A tooltip that jumps into place
A product page has a Delivery info button, and clicking it shows a tooltip above the button. To sit above the button, the tooltip needs its own height, and the height only exists once the browser has laid the tooltip out on the page. So the component renders, measures itself in an Effect, and renders again. Click Delivery info and watch the tooltip closely.
import { useEffect, useRef, useState } from "react";import { slowDown, watchPaints } from "./paints.js"; function Tooltip({ buttonTop, children }) { const ref = useRef(null); const [height, setHeight] = useState(0); slowDown(200); console.log(height === 0 ? "Render: height not measured yet" : "Render: height measured"); useEffect(() => { console.log("Effect: measured the tip"); setHeight(ref.current.offsetHeight); }, []); return ( <div ref={ref} className="tip" style={{ top: buttonTop - height - 8 }}> {children} </div> );} export default function App() { const [buttonTop, setButtonTop] = useState(null); function toggle(event) { watchPaints(); setButtonTop(buttonTop === null ? event.currentTarget.offsetTop : null); } return ( <div className="page"> <button onClick={toggle}>Delivery info</button> {buttonTop !== null && <Tooltip buttonTop={buttonTop}>Free delivery on orders over $30</Tooltip>} </div> );}For about half a second, the tooltip covers the button. Then it jumps up. Here is why. height starts at 0 (line 6), so the first render puts the tooltip's top at the button's top minus 0 minus 8 (line 16): right over the button. The Effect on lines 10 to 13 reads the real height through the ref and stores it, which renders Tooltip again with the right top. In between, the browser painted the wrong one.
Two helpers in the paints.js tab make that moment visible. slowDown(200) on line 7 keeps every render of Tooltip busy for 200 milliseconds, so the wrong frame stays on the screen long enough to see. watchPaints() on line 26 checks the page just before each frame the browser paints, and logs where the tip is. The console reads Paint after … ms: the tip covers the button, then the tip sits above the button. The styles.css tab, which the page loads, places the tip.
Now the same tooltip, measured in useLayoutEffect instead. Lines 1, 10 and 11 are the only lines that changed. Click Delivery info again.
import { useLayoutEffect, useRef, useState } from "react";import { slowDown, watchPaints } from "./paints.js"; function Tooltip({ buttonTop, children }) { const ref = useRef(null); const [height, setHeight] = useState(0); slowDown(200); console.log(height === 0 ? "Render: height not measured yet" : "Render: height measured"); useLayoutEffect(() => { console.log("Layout effect: measured the tip"); setHeight(ref.current.offsetHeight); }, []); return ( <div ref={ref} className="tip" style={{ top: buttonTop - height - 8 }}> {children} </div> );} export default function App() { const [buttonTop, setButtonTop] = useState(null); function toggle(event) { watchPaints(); setButtonTop(buttonTop === null ? event.currentTarget.offsetTop : null); } return ( <div className="page"> <button onClick={toggle}>Delivery info</button> {buttonTop !== null && <Tooltip buttonTop={buttonTop}>Free delivery on orders over $30</Tooltip>} </div> );}Nothing happens for almost a second, and then the tooltip appears above the button. It never covers it, and the console logs one paint: the tip sits above the button. React ran the layout effect, and the second render it asked for, before the browser painted anything.
useEffect, the browser can paint the tooltip before it has been measured, so the first frame shows it over the button and the next one moves it. With useLayoutEffect, the measuring and the second render happen before the first paint.A shop dresses its window behind a closed blind. If a sign doesn't fit, the staff move it before lifting the blind, and nobody outside sees the first try. If they lift the blind first and fix the sign after, people walking past see it jump. The longer they fiddle with the blind down, though, the longer everyone waits to see anything.
- In real life: Staff arrange the window display behind a closed blind
- In React: React renders and commits the new DOM
- In real life: They measure the space and move a sign before lifting the blind
- In React: A layout effect measures and sets state before the paint
- In real life: The blind goes up, and people outside see the display
- In React: The browser paints the screen
- In real life: Moving the sign after the blind is up: people see it jump
- In React: A
useEffectthat changes what the page shows - In real life: Fiddling for too long keeps the blind down
- In React: A slow layout effect holds up the paint
useLayoutEffect is a version of useEffect that React runs after the commit but before the browser paints. Use it to measure the page and fix what it shows before anyone sees it, like a tooltip's position. It holds up the paint, so everything else stays in useEffect.
When each kind of Effect runs
You met the three steps of a screen update in Render and commit: React renders your components, commits the changes to the DOM, and then the browser paints, drawing the new frame on the screen. Effects fit around these steps. This cart button logs every render, Effect and cleanup. Read the console, then click the button.
import { useEffect, useLayoutEffect, useState } from "react"; export default function App() { const [items, setItems] = useState(0); console.log("Render: items is", items); useEffect(() => { console.log("Effect: items is", items); return () => console.log("Effect cleanup: items was", items); }, [items]); useLayoutEffect(() => { console.log("Layout effect: items is", items); return () => console.log("Layout cleanup: items was", items); }, [items]); return <button onClick={() => setItems(items + 1)}>Cart items: {items}</button>;}The useEffect on lines 7 to 10 is written first, but the first lines in the console are Render: items is 0, with a small 2 beside it because StrictMode renders twice in development, then Layout effect: items is 0 from line 13, then Effect: items is 0 from line 8. Where a hook is written doesn't matter: React runs every layout effect in a commit before any useEffect. StrictMode then runs both cleanups and both setups once more, the development check from Synchronizing with effects.
Click Cart items: 0. After the render line, the console shows Layout cleanup: items was 0, Layout effect: items is 1, Effect cleanup: items was 0, and Effect: items is 1. Each kind cleans up its last run and then runs again, layout effects first.
useEffect. When a click or a key press caused the render, React runs the Effects just before the paint instead.The render, the commit and the layout effects happen in one piece of JavaScript work, and a browser can't paint while JavaScript is running. So nothing a layout effect does, even a state change, can reach the screen half done. A useEffect is different: React doesn't make the browser wait for it. Usually React runs useEffect in a later task, and the browser paints in between if a new frame is due. (The commit phase, a lesson near the end of the course, walks through React's code for this.)
There is one exception, and the Delivery info tooltip showed it. When a discrete event, like a click or a key press, caused the render, React runs the Effects right after the commit, before the paint, so the result of one click is ready before the next click arrives. React's own code says so, in this excerpt from ReactFiberWorkLoop.js:
// If the passive effects are the result of a discrete render, flush them
// synchronously at the end of the current task so that the result is
// immediately observable. Otherwise, we assume that they are not
// order-dependent and do not need to be observed by external systems, so we
// can wait until after paint.
// TODO: We can optimize this by not scheduling the callback earlier. Since we
// currently schedule the callback in multiple places, will wait until those
// are consolidated.
if (
includesSyncLane(pendingEffectsLanes) &&
(disableLegacyMode || root.tag !== LegacyRoot)
) {
flushPendingEffects();
}"Passive effects" is React's name for useEffect. A render caused by a discrete event gets the "sync lane", React's most urgent priority, and for that lane, lines 9 to 14 run the Effects at once. For anything else, the comment says, React can wait until after the paint. But the state updates an Effect makes are a different story. When React runs Effects, it sets the priority for updates made inside them to the default one at most, in these two lines:
const renderPriority = lanesToEventPriority(pendingEffectsLanes);
const priority = lowerEventPriority(DefaultEventPriority, renderPriority);Default updates render in a later task, and the browser can paint before that task starts. That's exactly what happened in the first tooltip: the Effect measured the tip before the paint, but the render with the right height came after it. React's reference for useEffect puts it the same way: the Effect may run before the paint, but the browser may paint before the state updates inside it. Each row of this table names a hook and what caused the render; read across for when the setup runs and when the state updates it makes are handled.
| The hook, and what caused the render | When the setup runs | When state updates inside it are handled |
|---|---|---|
useEffect, after a click, a key press, or another discrete event | Right after the commit, before the browser paints. | In a later render. The browser may paint before it. |
useEffect, after anything else: the first render, a timer, data arriving | After the commit, usually after the browser paints. | In a later render, after that paint. |
useLayoutEffect, after anything | After the commit, always before the browser paints. | At once, still before the paint. |
Measuring before the browser paints
Most components never need their size or position: they return JSX, and the browser works out the layout, where everything goes and how big it is. A tooltip, a menu or a popover is different. Its position depends on a size, and a size only exists after the browser has laid the element out. So it takes two renders:
- Render with a guess: here, a height of 0.
- Measure the real element with a ref, using a DOM method like
offsetHeightor getBoundingClientRect(). - Store the measurement in state, and render again with the right numbers.
All three steps have to happen before the browser paints, or the person sees the guess. useLayoutEffect gives you exactly that. In the second tooltip, line 10 calls it with the same function as before. React runs it after the commit, when ref.current is the real <div> and the browser can tell its height. Line 12 stores the height, and because the update comes from a layout effect, React renders and commits Tooltip again straight away, still before the paint. In React's commit code, it's the last step before React hands control back: line 4400 reads // If layout work was scheduled, flush it now.
Step through one click below, first with useEffect and then with useLayoutEffect. The left column is what React or the browser is doing, and the screen on the right changes only when the browser paints.
You click Delivery info. The click handler stores where the button is, and React starts a render.
With useEffect, step 5 paints the tip over the button, and step 8 paints it again in its place. With useLayoutEffect, the second render comes right after the layout effect, and step 7 is the first paint.
Without slowDown, the jump still happens; it's just too short to see. Press Edit & run on the first tooltip and change 200 to 0 on line 7. You can't see the tooltip move any more, but the console still logs a paint where the tip covers the button, followed a few milliseconds later by the right one. One wrong frame lasts about 16 milliseconds on a 60 Hz screen, which people notice as a flicker.
What useLayoutEffect costs
Click the two tooltips again and compare when something first appears. With useEffect, the console's first paint comes after about 400 milliseconds: the two slow renders of the first pass, one of them from StrictMode. With useLayoutEffect, the first paint comes after about 800 milliseconds, because the browser has to wait for the second pass too. Both end at the same moment; the layout effect only made the waiting screen stay still for longer.
That's the price. The code in a layout effect, and every render it asks for, blocks the paint: until they're done, the browser can't show anything, not even a pressed button or a typed letter. A quick measurement costs a fraction of a millisecond. Slow work in a layout effect, or a slow component it renders again, freezes the screen.
So start with useEffect, and switch an Effect to useLayoutEffect only when both of these are true:
- It reads the layout, like a size, a position, or a scroll offset, or it changes what the page shows.
- Without it, the person would see a wrong frame: a jump, a flicker, or a flash of the wrong content.
Each row of this table is one of React's three Effect hooks: when it runs, whether it holds up the paint, and what it's for.
| The hook | When React runs it | Does it hold up the paint? | Use it for |
|---|---|---|---|
useEffect | After the commit. Usually after the paint; after a click, just before it. | Not usually. Even after a click, the renders it asks for can wait. | Almost every Effect: connections, timers, subscriptions, analytics. |
useLayoutEffect | After the commit, always before the paint. | Yes. The browser waits for it and for every render it asks for. | Measuring the page and fixing what it shows before anyone sees it. |
useInsertionEffect | During the commit, before any layout effect. | Yes, but adding a <style> tag takes almost no time. | CSS-in-JS libraries adding <style> tags. Not app code. |
The two hooks take the same arguments, follow the same dependency rules, run their cleanups the same way, and both run twice in development when a component appears. Only the timing differs.
useInsertionEffect is for CSS-in-JS libraries
React has a third Effect hook, and you'll probably never write one yourself. Some styling libraries, called CSS-in-JS libraries, let you write CSS inside your JavaScript and add <style> tags to the page while the app runs. A new style rule changes the layout, so it has to be on the page before any layout effect measures anything. useInsertionEffect runs earlier than every layout effect, during the commit, for exactly this job.
Here is a tiny library of that kind, written for this lesson, in the css.js tab, and a sale badge that uses it. The badge measures its own padding in a layout effect. Read the console.
import { useLayoutEffect, useRef } from "react";import { useStyle } from "./css.js"; function SaleBadge() { const className = useStyle("sale-badge", "padding: 2px 12px; border: 2px solid;"); const ref = useRef(null); useLayoutEffect(() => { const padding = window.getComputedStyle(ref.current).paddingLeft; console.log("Layout effect: the badge's padding is", padding); }, []); return <span ref={ref} className={className}>Sale</span>;} export default function App() { return <p>Green tea <SaleBadge /></p>;}In css.js, useStyle is a function libraries hand out: a hook of their own, made from React's hooks, the kind you'll write in Reusing logic with custom hooks. On line 8 it calls useInsertionEffect, and lines 11 to 13 add a <style> tag with the rule, once per class name. In App.jsx, line 5 asks for the sale-badge rule, and the layout effect on lines 8 to 11 reads the padding the browser applied. The console shows Added the sale-badge rule, then Layout effect: the badge's padding is 12px, with a 2 beside it from StrictMode's extra run.
css.js adds the <style> tag first. When the layout effect in SaleBadge reads the badge's padding, the rule is already on the page, so it reads 12px.Press Edit & run, open the css.js tab, and change useInsertionEffect to useEffect on lines 1 and 8. Now the first line is Layout effect: the badge's padding is 0px: the layout effect measured before the rule existed. Only then does the console show Added the sale-badge rule, and StrictMode's second run reads 12px.
React keeps useInsertionEffect small on purpose:
- You can't set state in it. React warns
useInsertionEffect must not schedule updates. - Refs aren't filled in yet when it first runs, so it can't measure anything.
- Like every Effect, it doesn't run on the server.
React's docs say it plainly: unless you're writing a CSS-in-JS library, you want useEffect or useLayoutEffect. For your own components, CSS files and the style prop from Styling components need no Effect at all.
Layout effects and server rendering
Many React apps render their first screen to HTML on a server, so the page shows before the JavaScript arrives; frameworks like Next.js do it for you. A server has no screen, so there's no layout to measure and nothing to paint. React runs no Effects there: not useEffect, not useLayoutEffect, not useInsertionEffect. This code runs in Node.js, on a server, not in the page, so it has no result here:
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { renderToString } from "react-dom/server";
function Tooltip() {
const ref = useRef(null);
const [height, setHeight] = useState(0);
useLayoutEffect(() => {
console.log("Layout effect");
setHeight(ref.current.offsetHeight);
}, []);
useEffect(() => {
console.log("Effect");
}, []);
return <div ref={ref} className="tip">Height: {height}</div>;
}
console.log(renderToString(<Tooltip />));Line 2 imports renderToString, which turns components into an HTML string, and line 17 prints what it returns. The console shows one line: <div class="tip">Height: <!-- -->0</div>. Neither Layout effect nor Effect is logged, and the height is still 0. (The <!-- --> is a marker React adds between two pieces of text, so it can match them up again in the browser.)
Height: 0. The browser shows that HTML, and only once the JavaScript has loaded do the Effects run and change it to the measured height, 34 pixels here.So any component that measures itself shows its first-render guess in the server's HTML, and moves once the JavaScript has loaded, to a measured height like the 34 pixels in the picture. Older versions of React printed a warning about useLayoutEffect on the server; React 19 removed it, and the 19.3.1 release this course uses prints nothing. The jump is still yours to handle. You have a few choices:
- Show it only after a click. A tooltip, a menu or a popover usually isn't in the first screen at all, so the server never renders it. The Delivery info tooltip is like that.
- Accept the jump. Use
useEffect, and let the first screen show the guess until the JavaScript runs. - Render it only in the browser. Show nothing, or a placeholder, on the server, and the real component only in the browser.
For the last choice, React 19.3 added a browser() function to react-dom. Inside a component, call use(browser()). On the server, the component is left out, and the nearest <Suspense> boundary's fallback goes in the HTML instead; in the browser, it renders normally:
import { Suspense, use } from "react";
import { browser } from "react-dom";
function DeliveryTip() {
use(browser("The tip measures itself in the browser."));
return <Tooltip>Free delivery on orders over $30</Tooltip>;
}
export default function ProductPage() {
return (
<Suspense fallback={null}>
<DeliveryTip />
</Suspense>
);
}Line 2 imports browser, and line 5 marks DeliveryTip as browser-only; the text is a note for anyone reading the code. Lines 11 to 13 wrap it in <Suspense> with fallback={null}, so the server's HTML has nothing there. The code is only part of a file: Tooltip is the measured tooltip from earlier. Suspense and use get lessons of their own, Suspense and The use API, and server rendering gets Server rendering and hydration.
Show more, only when the text is cut off
A product list shows two lines of each description, with a Show more button. A short description fits, so a button there would be pointless. Whether a description is cut off depends on its length, the font, and the width of the screen, so the only way to know is to measure it. Click Show more on the carrot cake, then make the window narrower or wider and click Run again.
import { useLayoutEffect, useRef, useState } from "react"; function Description({ text }) { const ref = useRef(null); const [tooLong, setTooLong] = useState(false); const [open, setOpen] = useState(false); useLayoutEffect(() => { const box = ref.current; setTooLong(box.scrollHeight > box.clientHeight); }, [text]); return ( <div className="product"> <p ref={ref} className={open ? "text" : "text short"}>{text}</p> {tooLong && <button onClick={() => setOpen(!open)}>{open ? "Show less" : "Show more"}</button>} </div> );} export default function App() { return ( <> <Description text="Green tea: light and fresh." /> <Description text="Carrot cake: a soft, spiced sponge made with fresh carrots, brown sugar, cinnamon and a little nutmeg, topped with a thick layer of cream cheese frosting and chopped walnuts. Baked every morning in small batches, and best with a cup of chai or a strong black tea." /> </> );}Each Description starts with the class short (line 15), which the styles.css tab limits to two lines with max-height and overflow: hidden. The layout effect on lines 8 to 11 compares scrollHeight, the height of all the text, with clientHeight, the height you can see. When there is more text than room, line 10 sets tooLong, and line 16 shows the button. The green tea fits, so it gets no button; the carrot cake does.
Why a layout effect? With useEffect, the first paint could show the carrot cake without its button, and then the button would pop in and push the next product down. The array on line 11 is [text]: the measurement only changes when the text does, so opening the description doesn't measure again and hide the button.
- Put a tooltip above a button, using the tooltip's measured height
- Connect to the chat room while the chat is on screen
- In a CSS-in-JS library, add a <style> tag for a class before anything reads the layout
- Send a page view to an analytics server
- Scroll a new chat message into view before the screen shows it
- Start a countdown timer for a flash sale
- Check whether a product description is cut off, to decide whether to show Show more
- In a styling library, add the rule for a component's class name to the page
Sort each job: can it wait until after the paint, must it fix what the person sees before the paint, or is it a styling library adding its rules?
Common mix-ups
- "A layout effect runs before React changes the DOM." No. It runs after the commit, so the DOM is up to date and you can measure it. Only the paint waits.
- "
useEffectalways runs after the paint." No. After a click or a key press, React runs it before the paint. OnlyuseLayoutEffectpromises to run before the paint every time. - "After a click,
useEffectis as good asuseLayoutEffect." No. The Effect runs before the paint, but the render it asks for can wait until after it. The first tooltip jumped after a click. - "
useLayoutEffectis the safer choice, so use it everywhere." No. It holds up the paint, so slow work there freezes the screen. Use it only when the person would see a wrong frame. - "
useInsertionEffectis for my components' styles." No. It's for CSS-in-JS libraries. It can't set state or measure, and plain CSS needs no Effect. - "On the server, layout effects run before the HTML is sent." No. No Effect runs on the server, so the HTML shows the first render, and the Effects run later, in the browser.
Practice exercises
Each exercise shows its result under the code, or when you press Edit & run, with the console under it. Change the code, and the result runs again as you type. Make a guess first, then use the hints.
The useEffect is written above the useLayoutEffect. Which line does the console print first?
import { useEffect, useLayoutEffect } from "react";
export default function App() {
useEffect(() => {
console.log("A: useEffect");
});
useLayoutEffect(() => {
console.log("B: useLayoutEffect");
});
return <p>Order summary</p>;
}import { useEffect, useLayoutEffect } from "react"; export default function App() { useEffect(() => { console.log("A: useEffect"); }); useLayoutEffect(() => { console.log("B: useLayoutEffect"); }); return <p>Order summary</p>;}React runs every layout effect before any useEffect, so B: useLayoutEffect prints first, then A: useEffect. StrictMode then runs each once more.
Predict, then click Cup size: Small. What is the first line the console prints after the click?
import { useEffect, useLayoutEffect, useState } from "react"; export default function App() { const [size, setSize] = useState("Small"); useEffect(() => { console.log("Effect:", size); return () => console.log("Effect cleanup:", size); }, [size]); useLayoutEffect(() => { console.log("Layout effect:", size); return () => console.log("Layout cleanup:", size); }, [size]); return <button onClick={() => setSize("Large")}>Cup size: {size}</button>;}import { useEffect, useLayoutEffect, useState } from "react"; export default function App() { const [size, setSize] = useState("Small"); useEffect(() => { console.log("Effect:", size); return () => console.log("Effect cleanup:", size); }, [size]); useLayoutEffect(() => { console.log("Layout effect:", size); return () => console.log("Layout cleanup:", size); }, [size]); return <button onClick={() => setSize("Large")}>Cup size: {size}</button>;}After the click, React runs the layout effect's cleanup first, with the old value: Layout cleanup: Small. Then Layout effect: Large, Effect cleanup: Small, and Effect: Large.
The size guide covers its button for a moment before it moves up. Fix it so the first paint is already right. Which hook did you use?
import { useEffect, useRef, useState } from "react";import { slowDown, watchPaints } from "./paints.js"; function SizeGuide({ buttonTop }) { const ref = useRef(null); const [height, setHeight] = useState(0); slowDown(200); useEffect(() => { setHeight(ref.current.offsetHeight); }, []); return ( <div ref={ref} className="tip" style={{ top: buttonTop - height - 8 }}> Small: 250 ml. Large: 400 ml. </div> );} export default function App() { const [buttonTop, setButtonTop] = useState(null); function toggle(event) { watchPaints(); setButtonTop(buttonTop === null ? event.currentTarget.offsetTop : null); } return ( <div className="page"> <button onClick={toggle}>Size guide</button> {buttonTop !== null && <SizeGuide buttonTop={buttonTop} />} </div> );}import { useLayoutEffect, useRef, useState } from "react";import { slowDown, watchPaints } from "./paints.js"; function SizeGuide({ buttonTop }) { const ref = useRef(null); const [height, setHeight] = useState(0); slowDown(200); useLayoutEffect(() => { setHeight(ref.current.offsetHeight); }, []); return ( <div ref={ref} className="tip" style={{ top: buttonTop - height - 8 }}> Small: 250 ml. Large: 400 ml. </div> );} export default function App() { const [buttonTop, setButtonTop] = useState(null); function toggle(event) { watchPaints(); setButtonTop(buttonTop === null ? event.currentTarget.offsetTop : null); } return ( <div className="page"> <button onClick={toggle}>Size guide</button> {buttonTop !== null && <SizeGuide buttonTop={buttonTop} />} </div> );}With useLayoutEffect, React measures the guide and renders it again before the browser paints, so the console logs one paint: the tip sits above the button.
The second product name is cut off. Measure each heading before the paint, and show the short name when the full one doesn't fit. What does the second heading show then?
import { useRef, useState } from "react"; function ProductName({ full, short }) { const ref = useRef(null); const [fits, setFits] = useState(true); // Measure the heading here, before the browser paints. return <h3 ref={ref} className="name">{fits ? full : short}</h3>;} export default function App() { return ( <> <ProductName full="Green tea" short="Green tea" /> <ProductName full="Organic first flush Darjeeling tea" short="Darjeeling tea" /> </> );}import { useLayoutEffect, useRef, useState } from "react"; function ProductName({ full, short }) { const ref = useRef(null); const [fits, setFits] = useState(true); useLayoutEffect(() => { const heading = ref.current; setFits(heading.scrollWidth <= heading.clientWidth); }, [full]); return <h3 ref={ref} className="name">{fits ? full : short}</h3>;} export default function App() { return ( <> <ProductName full="Green tea" short="Green tea" /> <ProductName full="Organic first flush Darjeeling tea" short="Darjeeling tea" /> </> );}The heading is 170 pixels wide. The long name needs more, so scrollWidth is bigger than clientWidth and fits becomes false: the second heading shows Darjeeling tea, before the first paint.
The banner's layout effect measures it and also sends a view to an analytics server, pretended by slowDown(400). Split it in two, so only the measuring holds up the paint. Which hook sends the analytics after your fix?
import { useLayoutEffect, useRef, useState } from "react";import { slowDown } from "./paints.js"; function SaleBanner() { const ref = useRef(null); const [wide, setWide] = useState(false); useLayoutEffect(() => { setWide(ref.current.offsetWidth > 300); slowDown(400); // Pretend this sends the view to an analytics server. console.log("Sent: the offer was seen"); }, []); return <p ref={ref}>{wide ? "Sale: 20% off all tea, this week only" : "Sale: 20% off"}</p>;} export default function App() { const [open, setOpen] = useState(false); return ( <div> <button onClick={() => setOpen(!open)}>{open ? "Hide the offer" : "Show the offer"}</button> {open && <SaleBanner />} </div> );}import { useEffect, useLayoutEffect, useRef, useState } from "react";import { slowDown } from "./paints.js"; function SaleBanner() { const ref = useRef(null); const [wide, setWide] = useState(false); useLayoutEffect(() => { setWide(ref.current.offsetWidth > 300); }, []); useEffect(() => { slowDown(400); // Pretend this sends the view to an analytics server. console.log("Sent: the offer was seen"); }, []); return <p ref={ref}>{wide ? "Sale: 20% off all tea, this week only" : "Sale: 20% off"}</p>;} export default function App() { const [open, setOpen] = useState(false); return ( <div> <button onClick={() => setOpen(!open)}>{open ? "Hide the offer" : "Show the offer"}</button> {open && <SaleBanner />} </div> );}Only the measurement changes what the banner shows, so only it needs to run before the paint. Sending the view is work nobody sees, so it moves to useEffect, where it doesn't have to hold up the paint.
Click Returns: the tip goes off the top of the page. Place it below the button when there's no room above, and say where in the text. Where does it show after your fix?
import { useLayoutEffect, useRef, useState } from "react"; function Tooltip({ button, children }) { const ref = useRef(null); const [height, setHeight] = useState(0); useLayoutEffect(() => { setHeight(ref.current.offsetHeight); }, []); const top = button.top - height - 8; const place = "above"; return ( <div ref={ref} className="tip" style={{ top }}> {children} ({place}) </div> );} export default function App() { const [button, setButton] = useState(null); function toggle(event) { const { offsetTop, offsetHeight } = event.currentTarget; setButton(button === null ? { top: offsetTop, bottom: offsetTop + offsetHeight } : null); } return ( <div className="page"> <button onClick={toggle}>Returns</button> {button !== null && <Tooltip button={button}>Free returns within 30 days</Tooltip>} </div> );}import { useLayoutEffect, useRef, useState } from "react"; function Tooltip({ button, children }) { const ref = useRef(null); const [height, setHeight] = useState(0); useLayoutEffect(() => { setHeight(ref.current.offsetHeight); }, []); const fitsAbove = button.top - height - 8 >= 0; const top = fitsAbove ? button.top - height - 8 : button.bottom + 8; const place = fitsAbove ? "above" : "below"; return ( <div ref={ref} className="tip" style={{ top }}> {children} ({place}) </div> );} export default function App() { const [button, setButton] = useState(null); function toggle(event) { const { offsetTop, offsetHeight } = event.currentTarget; setButton(button === null ? { top: offsetTop, bottom: offsetTop + offsetHeight } : null); } return ( <div className="page"> <button onClick={toggle}>Returns</button> {button !== null && <Tooltip button={button}>Free returns within 30 days</Tooltip>} </div> );}With the measured height, button.top - height - 8 is below zero, so fitsAbove is false and the tip goes under the button: (below). Because the measuring happens in a layout effect, the tip never shows above first.
Check your understanding
Question 1 of 7This component appears for the first time. In what order do its three Effects first log?
Read the code, then predictimport { useEffect, useInsertionEffect, useLayoutEffect } from "react"; export default function App() { useEffect(() => { console.log("effect"); }); useLayoutEffect(() => { console.log("layout"); }); useInsertionEffect(() => { console.log("insertion"); }); return <p>Menu</p>; }Choose an answer to see the explanation.
Question 2 of 7When does React run the setup of a
useLayoutEffect?Choose an answer to see the explanation.
Question 3 of 7After a click, a
useEffectmeasures a tooltip and callssetHeight. Can the person see the tooltip in the wrong place?Choose an answer to see the explanation.
Question 4 of 7You click the button once. Which line does the console print first after the click?
Read the code, then predictimport { useEffect, useLayoutEffect, useState } from "react"; export default function App() { const [milk, setMilk] = useState("oat"); useEffect(() => { return () => console.log("effect cleanup:", milk); }, [milk]); useLayoutEffect(() => { console.log("layout:", milk); }, [milk]); return <button onClick={() => setMilk("soy")}>Milk: {milk}</button>; }Choose an answer to see the explanation.
Question 5 of 7Why should most Effects stay in
useEffectinstead ofuseLayoutEffect?Choose an answer to see the explanation.
Question 6 of 7Who should call
useInsertionEffect?Choose an answer to see the explanation.
Question 7 of 7What does a
useLayoutEffectdo while React renders a component to HTML on a server?Choose an answer to see the explanation.
Key takeaways
- React renders, commits, and runs layout effects in one go; then the browser paints; then React usually runs
useEffect. - After a click or a key press, React runs
useEffectbefore the paint, but the renders it asks for can still wait until after it. useLayoutEffectruns before the paint every time, and so do the renders it asks for. Use it to measure and fix what the screen would show wrong.- It holds up the paint, so keep it quick, and keep everything else in
useEffect. useInsertionEffectruns before layout effects and is for CSS-in-JS libraries only.- No Effect runs on the server. A measured component shows its first guess in the HTML until the JavaScript runs.
Measure in useLayoutEffect, everything else in useEffect.
A layout effect and the renders it asks for finish before the browser paints, so nobody sees the guess.
Coming next: The rules of hooks. It shows why every hook, from useState to useLayoutEffect, has to be called in the same order on every render.