useLayoutEffect — React Hook

AkashSDas
4 min readMar 27, 2024

--

useLayoutEffect is a hook provided by React that is similar to useEffect, but it runs synchronously immediately after all DOM mutations.

It's typically used when we need to perform actions that involve measuring, mutating, or interacting with the DOM synchronously before the browser paints.

It can hurt performance, so prefer useEffect when its possible.

Following is useLayoutEffect signature:

useLayoutEffect(setup, dependencies?)

setup is a function which contains our effect’s logic and it can optionally return another function i.e. for cleanup.

Before our component gets added to the DOM, React will run our setup function. After every re-render with changed dependencies, React will first run the cleanup function (if provided) with the old values, and then run the setup function with new values. Before our component is removed from the DOM, React will run the cleanup function.

dependencies (optional) is a list of all reactive values, used inside the setup function. Reactive values include props, state, and all the variables and functions declared directly inside our component body. React will compare each dependency with its previous value using the Object.is comparison.

The list of dependencies must have a constant number of items and be written inline. If we omit this then our Effect will re-run after every re-render of the component.

The following example shows usage of useLayoutEffect and when it gets triggered.

import { useState, useLayoutEffect, useEffect } from "react";

export default function App(): JSX.Element {
const [count, setCount] = useState<number>(0);
const [layoutDep, setLayoutDep] = useState<number>(0);
const [effectDep, setEffectDep] = useState<number>(0);

useLayoutEffect(
function effect() {
console.log("[useLayoutEffect] INIT 1");

return () => {
console.log("[useLayoutEffect] END 1");
};
},
[layoutDep]
);

useLayoutEffect(function effect() {
console.log("[useLayoutEffect] INIT 2");

return () => {
console.log("[useLayoutEffect] END 2");
};
}, []);

useEffect(
function effect() {
console.log("[useEffect] INIT 1");

return () => {
console.log("[useEffect] END 1");
};
},
[effectDep]
);

useEffect(function effect() {
console.log("[useEffect] INIT 2");

return () => {
console.log("[useEffect] END 2");
};
}, []);

console.log("[component] RENDER");

const performance = window.performance.now();
while (window.performance.now() - performance < 1000) {
// Artificial delay
}

return (
<div>
<h1>useEffect</h1>
<button onClick={set(setCount)}>Increment {count}</button>
<button onClick={set(setLayoutDep)}>
Trigger layout {layoutDep}
</button>
<button onClick={set(setEffectDep)}>
Trigger effect {effectDep}
</button>
</div>
);

function set(stateSetter: CallableFunction) {
return function () {
stateSetter((p: number) => {
console.log("[stateSetter] STATE");
return p + 1;
});
};
}
}

// Output for initial render only:
// [component] RENDER
// [component] RENDER
// [useLayoutEffect] INIT 1
// [useLayoutEffect] INIT 2
// [useEffect] INIT 1
// [useEffect] INIT 2
// [useLayoutEffect] END 1
// [useLayoutEffect] END 2
// [useEffect] END 1
// [useEffect] END 2
// [useLayoutEffect] INIT 1
// [useLayoutEffect] INIT 2
// [useEffect] INIT 1
// [useEffect] INIT 2

🚨 In Strict Mode (development), React will run one extra setup+cleanup cycle.

The code inside useLayoutEffect and all state updates scheduled from it block the browser from repainting the screen. When used excessively, this makes our app slow. When possible, prefer useEffect.

Effects only run on the client. They don’t run during server rendering

Issues that we’ll face with dependencies (same as useEffect):

  • If some of our dependencies are objects or functions defined inside the component, then 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. This happens because objects are compared via reference and not their value.
  • By default, when we read a reactive value from an Effect, we’ve to add it as a dependency. This ensures that your Effect “reacts” to every change of that value. For most dependencies, that’s the behavior we want.

Practical Usage

Consider the example where we want to render a tooltip in the UI.

  • Tooltip renders with the initial tooltipHeight = 0 (so the tooltip may be wrongly positioned).
  • React places it in the DOM and runs the code in useLayoutEffect.
  • Our useLayoutEffect measures the height of the tooltip content and triggers an immediate re-render.
  • Tooltip renders again with the real tooltipHeight (so the tooltip is correctly positioned).
  • React updates it in the DOM, and the browser finally displays the tooltip.

To do this, we 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.

Rendering in two passes and blocking the browser hurts performance. Try to avoid this when you can.

Code for this:

import { useState, useRef, useLayoutEffect } from "react";
import { createPortal } from "react-dom";

export default function App(): JSX.Element {
const [show, setShow] = useState<boolean>(false);

function toggle() {
setShow((p) => !p);
}

return (
<div>
<button onClick={toggle}>Toggle</button>
{show ? <TooltipExample /> : null}
</div>
);
}

function TooltipExample(): JSX.Element {
type BtnDOMRect = Pick<DOMRect, "left" | "top" | "bottom" | "right">;

const btnRef = useRef<HTMLButtonElement>(null);
const [btnRect, setBtnRect] = useState<BtnDOMRect>({
top: 0,
right: 0,
bottom: 0,
left: 0,
});

return (
<div style={{ margin: "2rem" }}>
<button ref={btnRef} onMouseEnter={handleBtnMouseEnter}>
Hover me
</button>

{createPortal(<Tooltip btnRect={btnRect} />, document.body)}
</div>
);

function Tooltip({ btnRect }: { btnRect: BtnDOMRect }): JSX.Element {
const ref = useRef<HTMLDivElement>(null);
const [tooltipHeight, setTooltipHeight] = useState(0);

// Measure tooltip height after initial render
useLayoutEffect(() => {
const { height } = ref.current?.getBoundingClientRect() ?? {
height: 0,
};
setTooltipHeight(height);
console.log("Measured tooltip height: " + height);
}, []);

// Calculate tooltip position
let tooltipX = 0;
let tooltipY = 0;
if (btnRect !== null) {
tooltipX = btnRect.left;
tooltipY = btnRect.top - tooltipHeight;

// If the tooltip doesn't fit above, place it below
if (tooltipY < 0) {
tooltipY = btnRect.bottom;
}
}

return (
<div
style={{
position: "absolute",
pointerEvents: "none",
left: 0,
top: 0,
transform: `translate3d(${tooltipX}px, ${tooltipY}px, 0)`,
}}
>
<div ref={ref} className="tooltip">
Content of the tooltip
</div>
</div>
);
}

function handleBtnMouseEnter() {
const rect = btnRef.current?.getBoundingClientRect();

if (rect) {
setBtnRect(rect);
}
}
}

--

--