Sometimes, you’re rendering so many components or those components are doing such complex things that you can’t afford to render them all quickly enough.
The human eye perceives things at around 60 frames per second. That means a single frame is around 16 milliseconds. So if your JavaScript is taking longer than 16ms to render, the browser won’t be able to keep up and your users will have a janky experience.
Unfortunately, sometimes you really just do have enough work to do that you can’t keep within that budget. There are certainly things React could do to speed things up and it’s constantly working on that. One innovation React came up with to help with this is called “concurrent rendering.”
If you’d like to understand concurrent rendering at a conceptual level, I suggest you check out the React v18 release announcement, watch Dan Abramov’s Talk at JSConf Iceland, or watch the React 18 Keynote.
Here’s the basic idea: instead of rendering everything all at once, React can break up the work into smaller chunks. When the rendering is taking longer than a threshold React manages, it will pause its work and let the browser do whatever work it needs to do. Then when React gets a turn, it’ll continue the work it was doing. Continously iterating toward finishing its work before finally updating the DOM with the updates of all that work.
In this way, React can keep the browser responsive and the user experience smooth even when it’s doing a lot of work.
The trick here is that React needs to know what things are priority. For example, a user drag-and-drop interaction should be prioritized over rendering a list of items. React can’t know what’s a priority and what’s not, so it needs you to tell it.
You can do this using the useDeferredValue
hook. The value you receive from
React will be a “deferred value” that React will prioritize less than other
work. This is useful for things like rendering a list of items, where the user
can still interact with the page even if the list isn’t fully rendered yet.
In the Suspense workshop we used this to make a list of items show stale content while we loaded more search results, but it is also used to de-prioritize rendering of less important things.
Here’s the example from the React docs:
function App() {const [text, setText] = useState('')const deferredText = useDeferredValue(text)return (<><input value={text} onChange={(e) => setText(e.currentTarget.value)} /><SlowList text={deferredText} /></>)}const SlowList = memo(function SlowList({ text }) {// something really slow happens here...return <div>{text}</div>})
It’s important to understand that the initial render of the SlowList
will
still be slow (there’s nothing “old” to show the user while React works in the
background), so as with other performance optimizations, it’s better to just
“make it faster” if you can. However, for if you can’t, useDeferredValue
can
clue React into the fact that re-rendering the App
component when the
deferredText
changes is less important than other work (like keeping the
value
prop on the input
up-to-date).
For some technical background on how useDeferredValue
does this, check
the documentation.
👨💼 We’ve got a performance problem with our Card
component in this UI. (🧝♂️
actually, I just added an arbitrary slowdown to simulate a problem to keep this
exercise simple while still teaching the lesson). As a result, every time we
type in the search
the characters are not responsive.
You might think that you can get away with just adding memo
around the Card
and we absolutely want you to do that, but once you try, you’ll notice that
typing a bunch in the search and then clearing the search is still a really slow
experience. We need concurrent rendering!
So please memo
-ize the Card
component, and add useDeferredValue
to the
query
you use to filter the cards and check the performance difference.
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>)}
import * as ReactDOM from 'react-dom/client'import { Form } from './form'import { QueryParamsProvider } from './params'import { MatchingPosts } from './posts'export function App() {return (<QueryParamsProvider><div className="app"><Form /><MatchingPosts /></div></QueryParamsProvider>)}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.tsx'type SearchParamsTuple = readonly [URLSearchParams,typeof setGlobalSearchParams,]const QueryParamsContext = createContext<SearchParamsTuple | null>(null)export function QueryParamsProvider({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 (<QueryParamsContext value={searchParamsTuple}>{children}</QueryParamsContext>)}export function useSearchParams() {const context = use(QueryParamsContext)if (!context) {throw new Error('useSearchParams must be used within a QueryParamsProvider')}return context}export const getQueryParam = (params: URLSearchParams) =>params.get('query') ?? ''
import { memo, useDeferredValue, useState } from 'react'import {generateGradient,getMatchingPosts,type BlogPost,} from '#shared/blog-posts.tsx'import { getQueryParam, useSearchParams } from './params'import { ButtonWithTooltip } from './tooltip'export function MatchingPosts() {const [searchParams] = useSearchParams()const query = getQueryParam(searchParams)const deferredQuery = useDeferredValue(query)const matchingPosts = getMatchingPosts(deferredQuery)return (<ul className="post-list">{matchingPosts.map((post) => (<Card key={post.id} post={post} />))}</ul>)}const Card = memo(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>{post.title.split('').map((c, index) => (<SlowThing key={index} />))}</li>)})function SlowThing() {// This artificially slows down renderingconst now = performance.now()while (performance.now() - now < 0.01) {// Do nothing for a bit...}return null}
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}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>}) {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