HomeAbout Me

Advanced React APIs 6: Layout Computation

By Daniel Nguyen
Published in React JS
May 26, 2025
1 min read
Advanced React APIs 6: Layout Computation

Layout Computation

Sometimes you need to compute the layout of some UI before it is actually displayed. This is often necessary if the size, position, or location of your UI depends on the size, position, or location of the other elements on the page or even itself (like the contents of a tooltip).

The trouble is, sometimes you don’t know the size, position, or location of the other elements on the page until the layout has been computed. So what happens is you render the UI, then you make your measurements, then you re-render the UI with the new measurements. This is inefficient and can cause flickering.

To avoid this problem in React, you can use the useLayoutEffect hook. This hook is designed with this specific use case in mind and is not a hook you’ll find yourself needing very often.

It literally has the same API as useEffect, but it runs synchronously after the DOM has been updated. You may recall from the useEffect exercise, the React flow diagram:

React Flow diagram showing mount, update, unmount
React Flow diagram showing mount, update, unmount

The useLayoutEffect hook runs after the DOM has been updated but before the browser has had a chance to paint the screen. This means you can make your measurements and then render the UI with the correct measurements before the user sees anything.

useLayoutEffect

👨‍💼 Our tooltip is great, but we do need to make measurements when we display it. We do this in a useEffect hook now with code like this:

useEffect(() => {
const rect = ref.current?.getBoundingClientRect()
if (!rect) return
const { height } = rect
setTooltipHeight(height)
}, [])

That height is used to determine whether the tooltip should appear above or below the target element (the heart in our case).

Kellie 🧝‍♂️ noticed on low-end devices, they’re seeing a little flicker so

she’s added
an arbitrary slowdown to our component to simulate that problem. To reproduce the problem, simply hover over a heart and you’ll notice it starts at the bottom of the heart and then flickers to the top (if there’s room on the top of the heart).

So your job is simple. Change useEffect to useLayoutEffect and that should fix things.

📜 Parts of this exercise was lifted from the React docs

📜 Learn more about the difference between useEffect and useLayoutEffect in useEffect vs useLayoutEffect.

form.tsx

import { getQueryParam, useSearchParams } from './params'
export function Form() {
const [searchParams, setSearchParams] = useSearchParams()
const query = getQueryParam(searchParams)
const words = query.split(' ').map((w) => w.trim())
const dogChecked = words.includes('dog')
const catChecked = words.includes('cat')
const caterpillarChecked = words.includes('caterpillar')
function handleCheck(tag: string, checked: boolean) {
const newWords = checked ? [...words, tag] : words.filter((w) => w !== tag)
setSearchParams(
{ query: newWords.filter(Boolean).join(' ').trim() },
{ replace: true },
)
}
return (
<form onSubmit={(e) => e.preventDefault()}>
<div>
<label htmlFor="searchInput">Search:</label>
<input
id="searchInput"
name="query"
type="search"
value={query}
onChange={(e) =>
setSearchParams({ query: e.currentTarget.value }, { replace: true })
}
/>
</div>
<div>
<label>
<input
type="checkbox"
checked={dogChecked}
onChange={(e) => handleCheck('dog', e.currentTarget.checked)}
/>{' '}
🐶 dog
</label>
<label>
<input
type="checkbox"
checked={catChecked}
onChange={(e) => handleCheck('cat', e.currentTarget.checked)}
/>{' '}
🐱 cat
</label>
<label>
<input
type="checkbox"
checked={caterpillarChecked}
onChange={(e) =>
handleCheck('caterpillar', e.currentTarget.checked)
}
/>{' '}
🐛 caterpillar
</label>
</div>
</form>
)
}

index.css

html,
body {
margin: 0;
}
.app {
margin: 40px auto;
max-width: 1024px;
form {
text-align: center;
}
}
.post-list {
list-style: none;
padding: 0;
display: flex;
gap: 20px;
flex-wrap: wrap;
justify-content: center;
li {
position: relative;
border-radius: 0.5rem;
overflow: hidden;
border: 1px solid #ddd;
width: 320px;
transition: transform 0.2s ease-in-out;
a {
text-decoration: none;
color: unset;
}
&:hover,
&:has(*:focus),
&:has(*:active) {
transform: translate(0px, -6px);
}
.post-image {
display: block;
width: 100%;
height: 200px;
}
button {
position: absolute;
font-size: 1.5rem;
top: 20px;
right: 20px;
background: transparent;
border: none;
outline: none;
&:hover,
&:focus,
&:active {
animation: pulse 1.5s infinite;
}
}
a {
padding: 10px 10px;
display: flex;
gap: 8px;
flex-direction: column;
h2 {
margin: 0;
font-size: 1.5rem;
font-weight: bold;
}
p {
margin: 0;
font-size: 1rem;
color: #666;
}
}
}
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.3);
}
100% {
transform: scale(1);
}
}
.tooltip-container {
position: absolute;
pointer-events: none;
left: 0;
top: 0;
transform: translate3d(var(--x), var(--y), 0);
z-index: 10;
}
.tooltip {
color: white;
background: #222;
border-radius: 4px;
padding: 4px;
}

index.tsx

import * as ReactDOM from 'react-dom/client'
import { Form } from './form'
import { SearchParamsProvider } from './params'
import { MatchingPosts } from './posts'
export function App() {
return (
<SearchParamsProvider>
<div className="app">
<Form />
<MatchingPosts />
</div>
</SearchParamsProvider>
)
}
const rootEl = document.createElement('div')
document.body.append(rootEl)
ReactDOM.createRoot(rootEl).render(<App />)

params.tsx

import { createContext, useCallback, use, useEffect, useState } from 'react'
import { setGlobalSearchParams } from '#shared/utils'
type SearchParamsTuple = readonly [
URLSearchParams,
typeof setGlobalSearchParams,
]
const SearchParamsContext = createContext<SearchParamsTuple | null>(null)
export function SearchParamsProvider({
children,
}: {
children: React.ReactNode
}) {
const [searchParams, setSearchParamsState] = useState(
() => new URLSearchParams(window.location.search),
)
useEffect(() => {
function updateSearchParams() {
setSearchParamsState((prevParams) => {
const newParams = new URLSearchParams(window.location.search)
return prevParams.toString() === newParams.toString()
? prevParams
: newParams
})
}
window.addEventListener('popstate', updateSearchParams)
return () => window.removeEventListener('popstate', updateSearchParams)
}, [])
const setSearchParams = useCallback(
(...args: Parameters<typeof setGlobalSearchParams>) => {
const searchParams = setGlobalSearchParams(...args)
setSearchParamsState((prevParams) => {
return prevParams.toString() === searchParams.toString()
? prevParams
: searchParams
})
return searchParams
},
[],
)
const searchParamsTuple = [searchParams, setSearchParams] as const
return (
<SearchParamsContext value={searchParamsTuple}>
{children}
</SearchParamsContext>
)
}
export function useSearchParams() {
const context = use(SearchParamsContext)
if (!context) {
throw new Error(
'useSearchParams must be used within a SearchParamsProvider',
)
}
return context
}
export const getQueryParam = (params: URLSearchParams) =>
params.get('query') ?? ''

posts.tsx

import { useState } from 'react'
import {
type BlogPost,
generateGradient,
getMatchingPosts,
} from '#shared/blog-posts'
import { getQueryParam, useSearchParams } from './params'
import { ButtonWithTooltip } from './tooltip'
export function MatchingPosts() {
const [searchParams] = useSearchParams()
const query = getQueryParam(searchParams)
const matchingPosts = getMatchingPosts(query)
return (
<ul className="post-list">
{matchingPosts.map((post) => (
<Card key={post.id} post={post} />
))}
</ul>
)
}
function Card({ post }: { post: BlogPost }) {
const [isFavorited, setIsFavorited] = useState(false)
return (
<li>
{isFavorited ? (
<ButtonWithTooltip
tooltipContent="Remove favorite"
onClick={() => setIsFavorited(false)}
>
❤️
</ButtonWithTooltip>
) : (
<ButtonWithTooltip
tooltipContent="Add favorite"
onClick={() => setIsFavorited(true)}
>
🤍
</ButtonWithTooltip>
)}
<div
className="post-image"
style={{ background: generateGradient(post.id) }}
/>
<a
href={post.id}
onClick={(event) => {
event.preventDefault()
alert(`Great! Let's go to ${post.id}!`)
}}
>
<h2>{post.title}</h2>
<p>{post.description}</p>
</a>
</li>
)
}

tooltip.tsx

import { useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
type Position = {
left: number
top: number
right: number
bottom: number
}
export default function Tooltip({
children,
targetRect,
}: {
children: React.ReactNode
targetRect: Position | null
}) {
const ref = useRef<HTMLDivElement | null>(null)
const [tooltipHeight, setTooltipHeight] = useState(0)
useLayoutEffect(() => {
const rect = ref.current?.getBoundingClientRect()
if (!rect) return
const { height } = rect
setTooltipHeight(height)
}, [])
let tooltipX = 0
let tooltipY = 0
if (targetRect !== null) {
tooltipX = targetRect.left
tooltipY = targetRect.top - tooltipHeight
if (tooltipY < 0) {
tooltipY = targetRect.bottom
}
tooltipX += window.scrollX
tooltipY += window.scrollY
}
// This artificially slows down rendering
let now = performance.now()
while (performance.now() - now < 100) {
// Do nothing for a bit...
}
return createPortal(
<TooltipContainer x={tooltipX} y={tooltipY} contentRef={ref}>
{children}
</TooltipContainer>,
document.body,
)
}
function TooltipContainer({
children,
x,
y,
contentRef,
}: {
children: React.ReactNode
x: number
y: number
contentRef: React.RefObject<HTMLDivElement | null>
}) {
return (
<div
className="tooltip-container"
style={{ '--x': `${x}px`, '--y': `${y}px` }}
>
<div ref={contentRef} className="tooltip">
{children}
</div>
</div>
)
}
export function ButtonWithTooltip({
tooltipContent,
...rest
}: React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
> & { tooltipContent: React.ReactNode }) {
const [targetRect, setTargetRect] = useState<Position | null>(null)
const buttonRef = useRef<HTMLButtonElement | null>(null)
function displayTooltip() {
const rect = buttonRef.current?.getBoundingClientRect()
if (!rect) return
setTargetRect({
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
})
}
const hideTooltip = () => setTargetRect(null)
return (
<>
<button
{...rest}
ref={buttonRef}
onPointerEnter={displayTooltip}
onPointerLeave={hideTooltip}
onFocus={displayTooltip}
onBlur={hideTooltip}
/>
{targetRect ? (
<Tooltip targetRect={targetRect}>{tooltipContent}</Tooltip>
) : null}
</>
)
}

Tags

#React

Share

Previous Article
Advanced React APIs 5: Portals

Table Of Contents

1
Layout Computation
2
useLayoutEffect

Related Posts

React Testing 8: Testing custom hook
September 09, 2025
1 min
© 2025, All Rights Reserved.
Powered By

Quick Links

About Me

Legal Stuff

Social Media