useLayoutEffect
useLayoutEffect
can hurt performance. Prefer useEffect
when possible.
useLayoutEffect
is a version of useEffect
that fires before the browser repaints the screen.
useLayoutEffect(setup, dependencies?)
Reference
useLayoutEffect(setup, dependencies?)
Call useLayoutEffect
to perform the layout measurements before the browser repaints the screen:
import { useState, useRef, useLayoutEffect } from 'react';
function Tooltip() {
const ref = useRef(null);
const [tooltipHeight, setTooltipHeight] = useState(0);
useLayoutEffect(() => {
const { height } = ref.current.getBoundingClientRect();
setTooltipHeight(height);
}, []);
// ...
Parameters
-
setup
: The function with your Effect's logic. Your setup function may also optionally return a cleanup function. Before your component is added to the DOM, React will run your setup function. After every re-render with changed dependencies, React will first run the cleanup function (if you provided it) with the old values, and then run your setup function with the new values. Before your component is removed from the DOM, React will run your cleanup function. -
optional
dependencies
: The list of all reactive values referenced inside of thesetup
code. Reactive values include props, state, and all the variables and functions declared directly inside your component body. If your linter is configured for React, it will verify that every reactive value is correctly specified as a dependency. The list of dependencies must have a constant number of items and be written inline like[dep1, dep2, dep3]
. React will compare each dependency with its previous value using theObject.is
comparison. If you omit this argument, your Effect will re-run after every re-render of the component.
Returns
useLayoutEffect
returns undefined
.
Caveats
-
useLayoutEffect
is a Hook, so you can only call it at the top level of your component or your own Hooks. You can't call it inside loops or conditions. If you need that, extract a component and move the Effect there. -
When Strict Mode is on, React will run one extra development-only setup+cleanup cycle before the first real setup. This is a stress-test that ensures that your cleanup logic "mirrors" your setup logic and that it stops or undoes whatever the setup is doing. If this causes a problem, implement the cleanup function.
-
If some of your dependencies are objects or functions defined inside the component, there is a risk that they will cause the Effect to re-run more often than needed. To fix this, remove unnecessary object and function dependencies. You can also extract state updates and non-reactive logic outside of your Effect.
-
Effects only run on the client. They don't run during server rendering.
-
The code inside
useLayoutEffect
and all state updates scheduled from it block the browser from repainting the screen. When used excessively, this makes your app slow. When possible, preferuseEffect
. -
If you trigger a state update inside
useLayoutEffect
, React will execute all remaining Effects immediately includinguseEffect
.
Usage
Measuring layout before the browser repaints the screen
Most components don't need to know their position and size on the screen to decide what to render. They only return some JSX. Then the browser calculates their layout (position and size) and repaints the screen.
Sometimes, that's not enough. Imagine a tooltip that appears next to some element on hover. If there's enough space, the tooltip should appear above the element, but if it doesn't fit, it should appear below. In order to render the tooltip at the right final position, you need to know its height (i.e. whether it fits at the top).
To do this, you need to render in two passes:
- Render the tooltip anywhere (even with a wrong position).
- Measure its height and decide where to place the tooltip.
- Render the tooltip again in the correct place.
All of this needs to happen before the browser repaints the screen. You don't want the user to see the tooltip moving. Call useLayoutEffect
to perform the layout measurements before the browser repaints the screen:
function Tooltip() {
const ref = useRef(null);
const [tooltipHeight, setTooltipHeight] = useState(0); // You don't know real height yet
useLayoutEffect(() => {
const { height } = ref.current.getBoundingClientRect();
setTooltipHeight(height); // Re-render now that you know the real height
}, []);
// ...use tooltipHeight in the rendering logic below...
}
Here's how this works step by step:
Tooltip
renders with the initialtooltipHeight = 0
(so the tooltip may be wrongly positioned).- React places it in the DOM and runs the code in
useLayoutEffect
. - Your
useLayoutEffect
measures the height of the tooltip content and triggers an immediate re-render. Tooltip
renders again with the realtooltipHeight
(so the tooltip is correctly positioned).- React updates it in the DOM, and the browser finally displays the tooltip.
Hover over the buttons below and see how the tooltip adjusts its position depending on whether it fits:
import ButtonWithTooltip from './ButtonWithTooltip.js';
export default function App() {
return (
<div>
<ButtonWithTooltip
tooltipContent={
<div>
This tooltip does not fit above the button.
<br />
This is why it's displayed below instead!
</div>
}
>
Hover over me (tooltip above)
</ButtonWithTooltip>
<div style={{ height: 50 }} />
<ButtonWithTooltip
tooltipContent={
<div>This tooltip fits above the button</div>
}
>
Hover over me (tooltip below)
</ButtonWithTooltip>
<div style={{ height: 50 }} />
<ButtonWithTooltip
tooltipContent={
<div>This tooltip fits above the button</div>
}
>
Hover over me (tooltip below)
</ButtonWithTooltip>
</div>
);
}
import { useState, useRef } from 'react';
import Tooltip from './Tooltip.js';
export default function ButtonWithTooltip({ tooltipContent, ...rest }) {
const [targetRect, setTargetRect] = useState(null);
const buttonRef = useRef(null);
return (
<>
<button
{...rest}
ref={buttonRef}
onPointerEnter={() => {
const rect = buttonRef.current.getBoundingClientRect();
setTargetRect({
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
});
}}
onPointerLeave={() => {
setTargetRect(null);
}}
/>
{targetRect !== null && (
<Tooltip targetRect={targetRect}>
{tooltipContent}
</Tooltip>
)
}
</>
);
}
import { useRef, useLayoutEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import TooltipContainer from './TooltipContainer.js';
export default function Tooltip({ children, targetRect }) {
const ref = useRef(null);
const [tooltipHeight, setTooltipHeight] = useState(0);
useLayoutEffect(() => {
const { height } = ref.current.getBoundingClientRect();
setTooltipHeight(height);
console.log('Measured tooltip height: ' + height);
}, []);
let tooltipX = 0;
let tooltipY = 0;
if (targetRect !== null) {
tooltipX = targetRect.left;
tooltipY = targetRect.top - tooltipHeight;
if (tooltipY < 0) {
// It doesn't fit above, so place below.
tooltipY = targetRect.bottom;
}
}
return createPortal(
<TooltipContainer x={tooltipX} y={tooltipY} contentRef={ref}>
{children}
</TooltipContainer>,
document.body
);
}
export default function TooltipContainer({ children, x, y, contentRef }) {
return (
<div
style={{
position: 'absolute',
pointerEvents: 'none',
left: 0,
top: 0,
transform: `translate3d(${x}px, ${y}px, 0)`
}}
>
<div ref={contentRef} className="tooltip">
{children}
</div>
</div>
);
}
.tooltip {
color: white;
background: #222;
border-radius: 4px;
padding: 4px;
}
Notice that even though the Tooltip
component has to render in two passes (first, with tooltipHeight
initialized to 0
and then with the real measured height), you only see the final result. This is why you need useLayoutEffect
instead of useEffect
for this example. Let's look at the difference in detail below.
useLayoutEffect
blocks the browser from repainting
React guarantees that the code inside useLayoutEffect
and any state updates scheduled inside it will be processed before the browser repaints the screen. This lets you render the tooltip, measure it, and re-render the tooltip again without the user noticing the first extra render. In other words, useLayoutEffect
blocks the browser from painting.
import ButtonWithTooltip from './ButtonWithTooltip.js';
export default function App() {
return (
<div>
<ButtonWithTooltip
tooltipContent={
<div>
This tooltip does not fit above the button.
<br />
This is why it's displayed below instead!
</div>
}
>
Hover over me (tooltip above)
</ButtonWithTooltip>
<div style={{ height: 50 }} />
<ButtonWithTooltip
tooltipContent={
<div>This tooltip fits above the button</div>
}
>
Hover over me (tooltip below)
</ButtonWithTooltip>
<div style={{ height: 50 }} />
<ButtonWithTooltip
tooltipContent={
<div>This tooltip fits above the button</div>
}
>
Hover over me (tooltip below)
</ButtonWithTooltip>
</div>
);
}
import { useState, useRef } from 'react';
import Tooltip from './Tooltip.js';
export default function ButtonWithTooltip({ tooltipContent, ...rest }) {
const [targetRect, setTargetRect] = useState(null);
const buttonRef = useRef(null);
return (
<>
<button
{...rest}
ref={buttonRef}
onPointerEnter={() => {
const rect = buttonRef.current.getBoundingClientRect();
setTargetRect({
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
});
}}
onPointerLeave={() => {
setTargetRect(null);
}}
/>
{targetRect !== null && (
<Tooltip targetRect={targetRect}>
{tooltipContent}
</Tooltip>
)
}
</>
);
}
import { useRef, useLayoutEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import TooltipContainer from './TooltipContainer.js';
export default function Tooltip({ children, targetRect }) {
const ref = useRef(null);
const [tooltipHeight, setTooltipHeight] = useState(0);
useLayoutEffect(() => {
const { height } = ref.current.getBoundingClientRect();
setTooltipHeight(height);
}, []);
let tooltipX = 0;
let tooltipY = 0;
if (targetRect !== null) {
tooltipX = targetRect.left;
tooltipY = targetRect.top - tooltipHeight;
if (tooltipY < 0) {
// It doesn't fit above, so place below.
tooltipY = targetRect.bottom;
}
}
return createPortal(
<TooltipContainer x={tooltipX} y={tooltipY} contentRef={ref}>
{children}
</TooltipContainer>,
document.body
);
}
export default function TooltipContainer({ children, x, y, contentRef }) {
return (
<div
style={{
position: 'absolute',
pointerEvents: 'none',
left: 0,
top: 0,
transform: `translate3d(${x}px, ${y}px, 0)`
}}
>
<div ref={contentRef} className="tooltip">
{children}
</div>
</div>
);
}
.tooltip {
color: white;
background: #222;
border-radius: 4px;
padding: 4px;
}
useEffect
does not block the browser
Here is the same example, but with useEffect
instead of useLayoutEffect
. If you're on a slower device, you might notice that sometimes the tooltip "flickers" and you briefly see its initial position before the corrected position.
import ButtonWithTooltip from './ButtonWithTooltip.js';
export default function App() {
return (
<div>
<ButtonWithTooltip
tooltipContent={
<div>
This tooltip does not fit above the button.
<br />
This is why it's displayed below instead!
</div>
}
>
Hover over me (tooltip above)
</ButtonWithTooltip>
<div style={{ height: 50 }} />
<ButtonWithTooltip
tooltipContent={
<div>This tooltip fits above the button</div>
}
>
Hover over me (tooltip below)
</ButtonWithTooltip>
<div style={{ height: 50 }} />
<ButtonWithTooltip
tooltipContent={
<div>This tooltip fits above the button</div>
}
>
Hover over me (tooltip below)
</ButtonWithTooltip>
</div>
);
}
import { useState, useRef } from 'react';
import Tooltip from './Tooltip.js';
export default function ButtonWithTooltip({ tooltipContent, ...rest }) {
const [targetRect, setTargetRect] = useState(null);
const buttonRef = useRef(null);
return (
<>
<button
{...rest}
ref={buttonRef}
onPointerEnter={() => {
const rect = buttonRef.current.getBoundingClientRect();
setTargetRect({
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
});
}}
onPointerLeave={() => {
setTargetRect(null);
}}
/>
{targetRect !== null && (
<Tooltip targetRect={targetRect}>
{tooltipContent}
</Tooltip>
)
}
</>
);
}
import { useRef, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import TooltipContainer from './TooltipContainer.js';
export default function Tooltip({ children, targetRect }) {
const ref = useRef(null);
const [tooltipHeight, setTooltipHeight] = useState(0);
useEffect(() => {
const { height } = ref.current.getBoundingClientRect();
setTooltipHeight(height);
}, []);
let tooltipX = 0;
let tooltipY = 0;
if (targetRect !== null) {
tooltipX = targetRect.left;
tooltipY = targetRect.top - tooltipHeight;
if (tooltipY < 0) {
// It doesn't fit above, so place below.
tooltipY = targetRect.bottom;
}
}
return createPortal(
<TooltipContainer x={tooltipX} y={tooltipY} contentRef={ref}>
{children}
</TooltipContainer>,
document.body
);
}
export default function TooltipContainer({ children, x, y, contentRef }) {
return (
<div
style={{
position: 'absolute',
pointerEvents: 'none',
left: 0,
top: 0,
transform: `translate3d(${x}px, ${y}px, 0)`
}}
>
<div ref={contentRef} className="tooltip">
{children}
</div>
</div>
);
}
.tooltip {
color: white;
background: #222;
border-radius: 4px;
padding: 4px;
}
To make the bug easier to reproduce, this version adds an artificial delay during rendering. React will let the browser paint the screen before it processes the state update inside useEffect
. As a result, the tooltip flickers:
import ButtonWithTooltip from './ButtonWithTooltip.js';
export default function App() {
return (
<div>
<ButtonWithTooltip
tooltipContent={
<div>
This tooltip does not fit above the button.
<br />
This is why it's displayed below instead!
</div>
}
>
Hover over me (tooltip above)
</ButtonWithTooltip>
<div style={{ height: 50 }} />
<ButtonWithTooltip
tooltipContent={
<div>This tooltip fits above the button</div>
}
>
Hover over me (tooltip below)
</ButtonWithTooltip>
<div style={{ height: 50 }} />
<ButtonWithTooltip
tooltipContent={
<div>This tooltip fits above the button</div>
}
>
Hover over me (tooltip below)
</ButtonWithTooltip>
</div>
);
}
import { useState, useRef } from 'react';
import Tooltip from './Tooltip.js';
export default function ButtonWithTooltip({ tooltipContent, ...rest }) {
const [targetRect, setTargetRect] = useState(null);
const buttonRef = useRef(null);
return (
<>
<button
{...rest}
ref={buttonRef}
onPointerEnter={() => {
const rect = buttonRef.current.getBoundingClientRect();
setTargetRect({
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
});
}}
onPointerLeave={() => {
setTargetRect(null);
}}
/>
{targetRect !== null && (
<Tooltip targetRect={targetRect}>
{tooltipContent}
</Tooltip>
)
}
</>
);
}
import { useRef, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import TooltipContainer from './TooltipContainer.js';
export default function Tooltip({ children, targetRect }) {
const ref = useRef(null);
const [tooltipHeight, setTooltipHeight] = useState(0);
// This artificially slows down rendering
let now = performance.now();
while (performance.now() - now < 100) {
// Do nothing for a bit...
}
useEffect(() => {
const { height } = ref.current.getBoundingClientRect();
setTooltipHeight(height);
}, []);
let tooltipX = 0;
let tooltipY = 0;
if (targetRect !== null) {
tooltipX = targetRect.left;
tooltipY = targetRect.top - tooltipHeight;
if (tooltipY < 0) {
// It doesn't fit above, so place below.
tooltipY = targetRect.bottom;
}
}
return createPortal(
<TooltipContainer x={tooltipX} y={tooltipY} contentRef={ref}>
{children}
</TooltipContainer>,
document.body
);
}
export default function TooltipContainer({ children, x, y, contentRef }) {
return (
<div
style={{
position: 'absolute',
pointerEvents: 'none',
left: 0,
top: 0,
transform: `translate3d(${x}px, ${y}px, 0)`
}}
>
<div ref={contentRef} className="tooltip">
{children}
</div>
</div>
);
}
.tooltip {
color: white;
background: #222;
border-radius: 4px;
padding: 4px;
}
Edit this example to useLayoutEffect
and observe that it blocks the paint even if rendering is slowed down.
Rendering in two passes and blocking the browser hurts performance. Try to avoid this when you can.
Troubleshooting
I'm getting an error: "useLayoutEffect
does nothing on the server"
The purpose of useLayoutEffect
is to let your component use layout information for rendering:
- Render the initial content.
- Measure the layout before the browser repaints the screen.
- Render the final content using the layout information you've read.
When you or your framework uses server rendering, your React app renders to HTML on the server for the initial render. This lets you show the initial HTML before the JavaScript code loads.
The problem is that on the server, there is no layout information.
In the earlier example, the useLayoutEffect
call in the Tooltip
component lets it position itself correctly (either above or below content) depending on the content height. If you tried to render Tooltip
as a part of the initial server HTML, this would be impossible to determine. On the server, there is no layout yet! So, even if you rendered it on the server, its position would "jump" on the client after the JavaScript loads and runs.
Usually, components that rely on layout information don't need to render on the server anyway. For example, it probably doesn't make sense to show a Tooltip
during the initial render. It is triggered by a client interaction.
However, if you're running into this problem, you have a few different options:
-
Replace
useLayoutEffect
withuseEffect
. This tells React that it's okay to display the initial render result without blocking the paint (because the original HTML will become visible before your Effect runs). -
Alternatively, mark your component as client-only. This tells React to replace its content up to the closest
<Suspense>
boundary with a loading fallback (for example, a spinner or a glimmer) during server rendering. -
Alternatively, you can render a component with
useLayoutEffect
only after hydration. Keep a booleanisMounted
state that's initialized tofalse
, and set it totrue
inside auseEffect
call. Your rendering logic can then be likereturn isMounted ? <RealContent /> : <FallbackContent />
. On the server and during the hydration, the user will seeFallbackContent
which should not calluseLayoutEffect
. Then React will replace it withRealContent
which runs on the client only and can includeuseLayoutEffect
calls. -
If you synchronize your component with an external data store and rely on
useLayoutEffect
for different reasons than measuring layout, consideruseSyncExternalStore
instead which supports server rendering.