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:
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.
👨💼 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) returnconst { height } = rectsetTooltipHeight(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
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.
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><inputid="searchInput"name="query"type="search"value={query}onChange={(e) =>setSearchParams({ query: e.currentTarget.value }, { replace: true })}/></div><div><label><inputtype="checkbox"checked={dogChecked}onChange={(e) => handleCheck('dog', e.currentTarget.checked)}/>{' '}🐶 dog</label><label><inputtype="checkbox"checked={catChecked}onChange={(e) => handleCheck('cat', e.currentTarget.checked)}/>{' '}🐱 cat</label><label><inputtype="checkbox"checked={caterpillarChecked}onChange={(e) =>handleCheck('caterpillar', e.currentTarget.checked)}/>{' '}🐛 caterpillar</label></div></form>)}
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;}
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 />)
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 constreturn (<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') ?? ''
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 ? (<ButtonWithTooltiptooltipContent="Remove favorite"onClick={() => setIsFavorited(false)}>❤️</ButtonWithTooltip>) : (<ButtonWithTooltiptooltipContent="Add favorite"onClick={() => setIsFavorited(true)}>🤍</ButtonWithTooltip>)}<divclassName="post-image"style={{ background: generateGradient(post.id) }}/><ahref={post.id}onClick={(event) => {event.preventDefault()alert(`Great! Let's go to ${post.id}!`)}}><h2>{post.title}</h2><p>{post.description}</p></a></li>)}
import { useLayoutEffect, useRef, useState } from 'react'import { createPortal } from 'react-dom'type Position = {left: numbertop: numberright: numberbottom: number}export default function Tooltip({children,targetRect,}: {children: React.ReactNodetargetRect: Position | null}) {const ref = useRef<HTMLDivElement | null>(null)const [tooltipHeight, setTooltipHeight] = useState(0)useLayoutEffect(() => {const rect = ref.current?.getBoundingClientRect()if (!rect) returnconst { height } = rectsetTooltipHeight(height)}, [])let tooltipX = 0let tooltipY = 0if (targetRect !== null) {tooltipX = targetRect.lefttooltipY = targetRect.top - tooltipHeightif (tooltipY < 0) {tooltipY = targetRect.bottom}tooltipX += window.scrollXtooltipY += window.scrollY}// This artificially slows down renderinglet 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.ReactNodex: numbery: numbercontentRef: React.RefObject<HTMLDivElement | null>}) {return (<divclassName="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) returnsetTargetRect({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}</>)}
Quick Links
Legal Stuff
Social Media