In one minute
Everything in a component’s body runs on every render, so a slow calculation there runs again even when its inputs didn’t change. The options, roughly in order of effort:
- Don’t repeat it. Cache the result with
useMemo(() => calc(a, b), [a, b]). - Make it faster. A better algorithm, less data, a cheaper library.
- Move it off the main thread with a Web Worker, so it can be slow without freezing the page.
- Deprioritize the render around it (Concurrent Rendering).
useMemo only helps when the inputs didn’t change. When they did (each keystroke of a search), the work still blocks. A worker fixes that: the main thread just sends a message, and React reads the result as a promise with use.
You’ll be able to: decide whether a calculation is worth memoizing, cache it with useMemo, and move it to a worker with a promise React can suspend on.
1function Search() {2 const [query, setQuery] = useState('')3 const results = useMemo(() => rankProducts(query), [query])4 return (<>5 <input value={query} onChange={e => setQuery(e.target.value)} />6 <Results names={results} />7 </>)8}- event
<input value={query} onChange={e => setQuery(e.target.value)} />Type “l”. - render phase
const results = useMemo(() => rankProducts(query), [query])The query changed, souseMemoruns the search, on the main thread. Recorded: about 55 ms for “l”. (useMemoonly helped for the unrelated “Refresh prices” click.) - browserThe input can’t show the letter until the search is done.
1// search.worker.ts2self.onmessage = e =>3 self.postMessage({ id: e.data.id, results: rankProducts(e.data.query) })45function Search() {6 const [query, setQuery] = useState('')7 const [promise, setPromise] = useState(() => searchInWorker(''))8 function handleChange(q) {9 setQuery(q) // urgent10 startTransition(() => setPromise(searchInWorker(q)))11 }12 return (<>13 <input value={query} onChange={e => handleChange(e.target.value)} />14 <Suspense fallback="Searching…"><Results names={use(promise)} /></Suspense>15 </>)16}- event
setQuery(q) // urgentType “l”. The input’s state is updated as an urgent update. - event
startTransition(() => setPromise(searchInWorker(q)))The search is sent to the worker (a message, almost free), and the new promise goes into state inside a transition. - browserRecorded: the next frame came within about one frame on every key. The main thread was free.
- render phase
self.postMessage({ id: e.data.id, results: rankProducts(e.data.query) })Meanwhile the worker ranks the products on its own thread. When it replies, the transition finishes and the results appear; until then the old results stay on screen.
The example: searching 150,000 products
The store’s search scores every product against the query and sorts them. The page also has a “Refresh prices” button that re-renders it for an unrelated reason.
export function rankProducts(query: string) {
const q = query.toLowerCase()
return catalog
.map((p) => ({ name: p.name, score: q ? score(p.name.toLowerCase(), q) : 1 }))
.filter((r) => r.score > 0)
.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
.slice(0, 20)
.map((r) => r.name)
}
The recordings: mount, click “Refresh prices”, then type “lamp” (200 ms between keys). rankProducts logs how long it took on the main thread.
1. In the render body
function SearchPlain() {
const [query, setQuery] = useState('')
const [, setRefresh] = useState(0)
const results = timed(query) // runs on every render
return (
<>
<input id="search" value={query} onChange={(e) => setQuery(e.target.value)} />
<button id="refresh" onClick={() => setRefresh((n) => n + 1)}>Refresh prices</button>
<Results names={results} />
</>
)
}
“Refresh prices”:
rankProducts("") ran on the main thread: 75 ms
Typing “lamp”:
rankProducts("l") ran on the main thread: 66 ms
keystroke "l": next frame after 70 ms
rankProducts("la") ran on the main thread: 46 ms
keystroke "a": next frame after 49 ms
rankProducts("lam") ran on the main thread: 20 ms
keystroke "m": next frame after 22 ms
rankProducts("lamp") ran on the main thread: 16 ms
keystroke "p": next frame after 17 ms
The search ran again for a click that had nothing to do with it, and each keystroke waited for it.
2. useMemo
function SearchMemo() {
const [query, setQuery] = useState('')
const [, setRefresh] = useState(0)
const results = useMemo(() => timed(query), [query]) // runs when query changes
return (
<>
<input id="search" value={query} onChange={(e) => setQuery(e.target.value)} />
<button id="refresh" onClick={() => setRefresh((n) => n + 1)}>Refresh prices</button>
<Results names={results} />
</>
)
}
“Refresh prices”:
[]
Typing “lamp”:
rankProducts("l") ran on the main thread: 65 ms
keystroke "l": next frame after 68 ms
rankProducts("la") ran on the main thread: 44 ms
keystroke "a": next frame after 46 ms
rankProducts("lam") ran on the main thread: 20 ms
keystroke "m": next frame after 23 ms
rankProducts("lamp") ran on the main thread: 21 ms
keystroke "p": next frame after 24 ms
The refresh no longer runs the search. Typing still does, because the query really changed, and each keystroke still waits.
3. A Web Worker
The search moves into a worker file. The same rankProducts function runs there, on another thread:
import { rankProducts } from './search'
self.onmessage = (e: MessageEvent<{ id: number; query: string }>) => {
self.postMessage({ id: e.data.id, results: rankProducts(e.data.query) })
}
On the main thread, a small client turns each request into a promise:
const worker = new Worker(new URL('./search.worker.ts', import.meta.url), { type: 'module' })
const waiting = new Map<number, (names: string[]) => void>()
let nextId = 0
worker.onmessage = (e: MessageEvent<{ id: number; results: string[] }>) => {
waiting.get(e.data.id)?.(e.data.results)
waiting.delete(e.data.id)
}
function searchInWorker(query: string) {
return new Promise<string[]>((resolve) => {
const id = nextId++
waiting.set(id, resolve)
worker.postMessage({ id, query })
})
}
The component keeps the promise in state, reads it with use, and replaces it inside a transition:
function SearchWorker() {
const [query, setQuery] = useState('')
const [resultsPromise, setResultsPromise] = useState(() => searchInWorker(''))
const [isPending, startTransition] = useTransition()
function handleChange(q: string) {
setQuery(q) // urgent: the input updates right away
startTransition(() => setResultsPromise(searchInWorker(q))) // results follow when ready
}
return (
<>
<input id="search" value={query} onChange={(e) => handleChange(e.target.value)} />
<div style={{ opacity: isPending ? 0.6 : 1 }}>
<Suspense fallback={<p>Searching…</p>}>
<WorkerResults promise={resultsPromise} />
</Suspense>
</div>
</>
)
}
function WorkerResults({ promise }: { promise: Promise<string[]> }) {
return <Results names={use(promise)} />
}
Typing “lamp”:
keystroke "l": next frame after 17 ms
keystroke "a": next frame after 2 ms
keystroke "m": next frame after 2 ms
keystroke "p": next frame after 4 ms
Every keystroke painted within about one frame (~16 ms), usually much less. The results (the same first five as before) arrived when the worker replied:
Desk Lamp 1
Desk Lamp 10
Desk Lamp 100
Desk Lamp 1000
Desk Lamp 1001
How it works
useMemocompares dependencies withObject.isand returns the cached value when they all match. It’s a performance hint, not a guarantee: React may drop the cache (for example, for offscreen content).- Is it worth it? react.dev’s rule of thumb: time it (
console.time); if it takes 1 ms or more, memoizing may help. Measure with CPU throttling and in a production build. - A worker is another thread. It shares no memory with the page: data is copied with
postMessage(structured clone), so send queries and small results, not huge objects back and forth. The worker loads its own copy of the code and data. Libraries like Comlink hide the message plumbing behind function calls. use(promise)+ transition. The promise must be stable (kept in state, not created during render), or every render would start a new search and suspend forever. Updating it insidestartTransitionkeeps the old results visible while the new ones load, instead of showing theSuspensefallback on every keystroke.
Common mistakes
useMemoon cheap calculations. It adds a deps array, a comparison and memory, for nothing.- Unstable dependencies (objects created during render): the cache never hits.
- Creating the promise during render (
use(searchInWorker(q))): a new promise each render. - Shipping huge data to a worker on every call. Load it in the worker once.
Interview Q&A
Why is a slow calculation in a component a problem?
The component body runs on every render, so the calculation repeats even when its inputs haven’t changed. Recorded: “Refresh prices” re-ran the 150,000-product search.
When is useMemo worth it?
When the calculation is measurably slow (about 1 ms or more) and the component re-renders with the same inputs. Recorded: with useMemo, the refresh click ran nothing.
What doesn't useMemo fix?
The case where the inputs change. Each keystroke changed the query, so the search still ran and the input still waited for it (recorded: tens of milliseconds per letter).
How does a Web Worker help, if the work is the same?
The work runs on another thread, so the main thread stays free to handle input and paint. Recorded: the next frame came within about one frame of every keystroke, instead of waiting for the search.
How do you show the worker's async result in React 19?
Keep the promise in state, read it with use(promise) inside Suspense, and replace the promise inside startTransition so the old results stay visible while the new ones compute.
Related
- Concurrent Rendering: deprioritize slow rendering instead.
- React Re-rendering:
useMemoanduseCallbackbasics. - The Suspense module:
use, promises and transitions in depth.
Sources
- react.dev:
useMemo,use - MDN: Using Web Workers
- Kent C. Dodds: When to useMemo and useCallback
- Comlink: calling worker functions like local ones
- Topic order inspired by Kent C. Dodds’ EpicReact React Performance workshop; the example app and code here are this handbook’s own.
Primary source: react.dev/reference/react/useMemo