HomeAbout Me

React Performance 3: Concurrent Rendering

By Daniel Nguyen
Published in React JS
July 04, 2025
2 min read
React Performance 3: Concurrent Rendering

Concurrent Rendering

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.

You must `memo`-ize the slow component. This is because React needs to be able to rerender the component with the previous value very quickly. So if you don't use `memo` on the component, React is forced to rerender the entire component with the old value anyway which defeats the purpose of `useDeferredValue` in this case.

useDeferredValue + memo

👨‍💼 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.

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.tsx

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 />)

params.tsx

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 const
return (
<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') ?? ''

posts.tsx

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 ? (
<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>
{post.title.split('').map((c, index) => (
<SlowThing key={index} />
))}
</li>
)
})
function SlowThing() {
// This artificially slows down rendering
const now = performance.now()
while (performance.now() - now < 0.01) {
// Do nothing for a bit...
}
return null
}

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
}
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>
}) {
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
React Performance 2: Optimize Context

Table Of Contents

1
Concurrent Rendering
2
useDeferredValue + memo

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