INP in frameworks
Keep typing and clicks fast in React and other frameworks: deferred values, transitions, memo and signals, clicks during hydration, and virtual lists, measured.
- 01Read a framework's share of an interactionTell React's work from the browser's style and layout in a key press or a click, and see which one INP waited for.
- 02Let the slow part waitKeep a search box quick with useDeferredValue or startTransition, and know what they can't split up.
- 03Do less on each changeRun only what changed with memo or signals, and keep long lists short with a virtual list.
Why do Asha's letters come late?
Ravi has rebuilt the shop's search page in React. A framework like React keeps the page in step with your data. You change the data, and React changes the page to match. The new page starts empty, with a search box and the words "Type to search 3,000 teas."
Asha wants to see the house blends. She types blend, a key every 120 ms, as she did in lesson 28.1. For almost half a second, her screen doesn't change. Then "ble" appears in the box, with a long list under it. Why does she have to wait? React has tools for this, called transitions. Do they help?
Here is what Asha saw. These are real frames Chrome captured. A frame is one picture the browser draws on the screen. The top row is Ravi's first page. The bottom row is the same page with one change, which this lesson explains.
Here are the same moments as code:
// Asha types "blend", a key every 120 ms. Times are in ms after her first key.const plain = { firstFrame: 467.4, showed: "ble" };const deferred = { firstFrame: 21.5, showed: "b" }; console.log(`Plain update: the first new frame came at ${plain.firstFrame.toFixed(1)} ms and showed "${plain.showed}"`);console.log(`useDeferredValue: the first new frame came at ${deferred.firstFrame.toFixed(1)} ms and showed "${deferred.showed}"`);- Line 1 is a comment. Times are in milliseconds (ms) after Asha's first key. A millisecond is a thousandth of a second.
- Lines 2 and 3 hold the two pages.
firstFrameis the first frame that showed anything new.showedis what the box said in it. - Line 5 prints
Plain update: the first new frame came at 467.4 ms and showed "ble". - Line 6 prints
useDeferredValue: the first new frame came at 21.5 ms and showed "b".
On the first page, Asha had pressed four keys before anything new showed. On the second, her first letter showed after 21.5 ms. Why it matters: where React does its work decides when Asha sees her letters.
You ask a librarian for books about blends, and you spell it out. At "b", she pulls every card with a b and lays them all on the desk. Only then does she look up for your next letter. By the time she hears "l", you have said the whole word.
- In real life: Each letter you spell out
- In React: Each key press
- In real life: Laying out every matching card
- In React: React building the list for that letter
- In real life: The desk filling up with cards
- In React: The browser's style and layout for the new rows
- In real life: Your next letter, waiting
- In React: The next key's input delay
A framework turns each change to your data into work: its own work, then the browser's. Until both are done, the next frame can't show, and the next key can't start. This lesson tries four ways to make that work wait, shrink or disappear, and times each one in Chrome.
How the course recorded it
- Seven pages: three search pages and four "All teas" pages. Each is the Leaf and Kettle shop from lesson 26.1, with the 3,000 teas of lesson 28.2. They use React 19.3.1, Preact 11.2.0 with @preact/signals 2.11.4, and TanStack Virtual 3.14.26, built for production.
- Asha's phone: this computer in Chrome 157, with its processor, the CPU, slowed 4 times, as in lessons 28.1 and 28.2. Her window was 412 by 480 pixels.
- Real keys and clicks: they went through Chrome's DevTools protocol, the way programs drive Chrome. Each key went down, then up 60 ms later. Chrome passes key presses and mouse clicks straight to the page.
- Three visits each: every page was loaded three times, taking turns. The lesson quotes the middle visit.
- Two clocks: Event Timing gives how long each key or click took. Chrome's screenshots show what was on screen, a moment after each frame.
Every key renders the whole list
Here is Ravi's search on the first page. It has two parts: the box, and the list of teas that match.
function Search() { const [query, setQuery] = useState(""); return ( <> <SearchBox value={query} onChange={setQuery} /> <Results query={query} /> </> );}- Line 1 makes a component: a function that returns what part of the page should look like.
- Line 2 makes a piece of state,
query. It holds the box's text.setQuerychanges it. - Line 5 draws the box. Each key calls
setQuerywith the box's new text. - Line 6 draws the list, and gives it the same text.
When state changes, React calls the component again. This is called a render. React then changes the page to match the result. That second step is the commit. React's docs, in Render and Commit, add that rendering goes down the tree: if a component returns other components, React renders those next.
Here is the list. It's the slow part:
export const Results = memo(function Results({ query }) { const words = query.toLowerCase(); const found = query ? TEAS.filter((tea) => tea.name.toLowerCase().includes(words)) : []; return ( <> <p id="about">{query ? `${found.length} teas match "${query}"` : "Type to search 3,000 teas."}</p> <ul id="teas" className="tea-list"> {found.map((tea) => ( <TeaRow key={tea.tea} tea={tea} /> ))} </ul> </> );});- Line 1 wraps the list in
memo. The next section explains why. - Line 3 finds the teas whose names include the box's text. With an empty box, it finds none.
- Line 6 says how many teas match.
- Lines 8 to 10 draw one
TeaRowfor each tea. A row has the tea's name, its price and an Add button, as in lesson 28.2.
Asha's b matched 2,993 teas: Rooibos and the 2,992 house blends. So React ran TeaRow 2,993 times, built the rows, and put them in the page. All of that ran inside the key press. Here is what the main thread did for the b. The main thread is the one thread that runs the page's JavaScript and draws its frames.
// The main thread's work for the b, on the plain page (ms).const work = { react: 141.7, style: 33.0, layout: 138.3 }; console.log(`React built and placed 2993 rows: ${work.react.toFixed(1)} ms`);console.log(`The browser worked out their styles: ${work.style.toFixed(1)} ms`);console.log(`The browser laid them out: ${work.layout.toFixed(1)} ms`);console.log(`Together: ${(work.react + work.style + work.layout).toFixed(1)} ms`);- Line 2 holds three times from Chrome's trace, a detailed record of everything Chrome did.
reactis React's render and commit.styleandlayoutare the browser's work on the new rows. - Lines 4 and 5 print
React built and placed 2993 rows: 141.7 msandThe browser worked out their styles: 33.0 ms. - Lines 6 and 7 print
The browser laid them out: 138.3 msandTogether: 313.0 ms.
Lesson 28.2 met these two steps. Style works out which CSS rules apply to each element. Layout works out each element's size and place. Both ran before the next frame. Why it matters: React's work was only part of the cost. The browser's work on the new rows was bigger.
Meanwhile, Asha kept typing. Here is every key press:
// Each key press on the plain page, in ms.const keys = [ { letter: "b", pressed: 0.0, waited: 0.3, took: 465.6 }, { letter: "l", pressed: 120.5, waited: 197.7, took: 345.1 }, { letter: "e", pressed: 240.3, waited: 146.0, took: 225.3 }, { letter: "n", pressed: 361.9, waited: 96.9, took: 168.6 }, { letter: "d", pressed: 480.5, waited: 11.5, took: 50.0 },]; for (const key of keys) console.log(`${key.letter}: waited ${key.waited.toFixed(1)} ms, took ${key.took.toFixed(1)} ms`);- Lines 3 to 7 hold her five key presses.
pressedis when the key went down.waitedis how long it waited before its handlers could start. That wait is its input delay.tookis the key press's time as INP counts it: from the key going down to the next frame. - Line 10 prints
b: waited 0.3 ms, took 465.6 ms, thenl: waited 197.7 ms, took 345.1 ms, and so on down to the d.
INP, Interaction to Next Paint, is roughly a visit's slowest click, tap or key press, as lesson 26.1 showed. A good INP is 200 ms or less. This visit's INP was 465.6 ms, the b. The l and the e waited behind the b's work. Why it matters: a key that renders a big list holds back every key after it.
React treats a key press as urgent. It renders the update at once, from start to finish, without stopping. The React lesson Transitions calls this an urgent update. The next two sections let the list wait.
Let the list wait with useDeferredValue
The box must show each letter at once. The list can wait a moment. React has a hook for that: a function, starting with use, that a component calls to get something from React. This one is useDeferredValue. The React lesson Deferred values teaches it. Here it is on the second page:
function Search() { const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); return ( <> <SearchBox value={query} onChange={setQuery} /> <Results query={deferredQuery} /> </> );}- Line 3 asks React for a deferred copy of
query: a copy that is allowed to lag behind. - Line 6 still gives the box the real
query, so the box shows each key at once. - Line 7 gives the list the deferred copy.
React's docs say what happens on each key. First, React renders with the old value. Then it tries another render "in the background with the new value". The first render is quick, because the list is wrapped in memo. memo tells React to skip a component when its props haven't changed. The list's copy hasn't changed yet, so React skips the whole list.
At a meeting, a note-taker writes each sentence on her notepad the moment it is said. Between speakers, she types a clean copy. If a speaker corrects herself, the note-taker types only the new version. When the copy is done, she pins all of it to the board at once.
- In real life: Writing on the notepad at once
- In React: The box showing each key
- In real life: Typing the clean copy between speakers
- In React: React rendering the list in small pieces
- In real life: Skipping a sentence that was corrected
- In React: Skipping the lists for bl and ble
- In real life: Pinning the whole copy up at once
- In React: React putting the list on the page in one go
Small pieces of 5 ms
How does React render in the background? In small pieces. Before each step, it asks its scheduler, the part of React that decides when work runs, whether to stop. The scheduler says stop after 5 ms: frameYieldMs in React's source. React then hands the main thread back. The browser can draw a frame or handle a key. Then React picks up where it stopped.
The trace shows it. For the b's list, React worked in 13 pieces, each 5.0 to 5.7 ms long, and then one last piece. The b itself showed at 21.5 ms. Why it matters: React's own work no longer held the box back.
And React did less work in all. Here are the lists it started on each page:
// The lists React started while Asha typed "blend", on each page.const plain = ["b", "bl", "ble", "blen", "blend"];const deferred = ["b", "blen", "blend"]; console.log(`Plain update: ${plain.length} lists: ${plain.join(", ")}`);console.log(`useDeferredValue: ${deferred.length} lists: ${deferred.join(", ")}`);- Lines 2 and 3 hold the lists React started on each page, in order.
- Lines 5 and 6 print
Plain update: 5 lists: b, bl, ble, blen, blendanduseDeferredValue: 3 lists: b, blen, blend.
With useDeferredValue, React never started the lists for bl and ble. By the time it came back to the list, the box already said blen. React's docs say it "will always use the latest provided value". Why it matters: lists nobody would see cost nothing.
Why the l still waited
Here are the key presses on this page:
// Each key press on the useDeferredValue page, in ms.const keys = [ { letter: "b", pressed: 0.0, waited: 0.7, took: 19.4 }, { letter: "l", pressed: 120.2, waited: 235.8, took: 290.4 }, { letter: "e", pressed: 239.9, waited: 122.7, took: 170.7 }, { letter: "n", pressed: 358.7, waited: 45.8, took: 60.0 }, { letter: "d", pressed: 479.7, waited: 12.7, took: 44.1 },]; for (const key of keys) console.log(`${key.letter}: waited ${key.waited.toFixed(1)} ms, took ${key.took.toFixed(1)} ms`);- Lines 3 to 7 hold the five key presses, as before.
- Line 10 prints
b: waited 0.7 ms, took 19.4 ms. Then it printsl: waited 235.8 ms, took 290.4 ms.
The b was quick. But the l waited 235.8 ms, longer than on the plain page. What was the main thread doing? Here is the start of both visits, to scale:
// What the l waited for on the useDeferredValue page (ms).const waited = { lastPiece: 60.8, style: 33.2, layout: 141.0 }; console.log(`The rest of React's last piece: ${waited.lastPiece.toFixed(1)} ms`);console.log(`The browser's style and layout: ${(waited.style + waited.layout).toFixed(1)} ms`);console.log(`The l waited 235.8 ms in all.`);- Line 2 holds three times from the trace.
- Lines 4 to 6 print
The rest of React's last piece: 60.8 ms,The browser's style and layout: 174.2 msandThe l waited 235.8 ms in all.
React's last piece for the b took 90.2 ms, not 5. In it, React finished the list and put all 2,993 rows in the page. React can pause while it works out a list. It doesn't pause while it changes the page. React's post on React 18 says it "waits to perform DOM mutations until the end, once the entire tree has been evaluated".
The l arrived in the middle of that piece. Then the browser styled and laid out the 2,993 new rows, in one task. The l waited for both. Why it matters: a deferred value moves React's work out of the way. It doesn't make the page smaller. The last section of this lesson does that.
Pick a page to see each of Asha's key presses. The third page is the next section's.
- b, pressed at 0.0 ms
took 465.6 mswaited 0.3 ms; the letter showed at 465.6 ms; the list for "b" never showed - l, pressed at 120.5 ms
took 345.1 mswaited 197.7 ms; the letter showed at 465.5 ms; the list for "bl" never showed - e, pressed at 240.3 ms
took 225.3 mswaited 146.0 ms; the letter showed at 465.6 ms; the list for "ble" showed at 467.4 ms - n, pressed at 361.9 ms
took 168.6 mswaited 96.9 ms; the letter showed at 530.5 ms; the list for "blen" never showed - d, pressed at 480.5 ms
took 50.0 mswaited 11.5 ms; the letter showed at 530.6 ms; the list for "blend" showed at 532.9 ms
Took is the key press as INP counts it, from the key going down to the next frame. Waited is the part before its handlers could start. A list that never showed was skipped, or covered by a newer one before a frame was drawn.
startTransition does the same from the handler
The third page uses a transition. A transition is a state update you mark as not urgent. The React lesson Transitions teaches useTransition, the hook that starts one and tells you it's pending. This page uses startTransition, the same function without the hook.
function Search() { const [text, setText] = useState(""); const [query, setQuery] = useState(""); function search(value) { setText(value); startTransition(() => setQuery(value)); } return ( <> <SearchBox value={text} onChange={search} /> <Results query={query} /> </> );}- Lines 2 and 3 keep two pieces of state:
textfor the box, andqueryfor the list. - Line 5 sets
textat once. That's an urgent update, so the box shows the key. - Line 6 sets
queryinsidestartTransition. React marks that update as a transition. - Lines 10 and 11 give the box
text, and the listquery.
Why two pieces of state? React's docs say "Transition updates can't be used to control text inputs." The box needs its own urgent state. Here is the slowest key on each page:
// The slowest key press on each page: what INP reports for the visit (ms).const pages = [ { name: "Plain update", inp: 465.6, key: "b" }, { name: "useDeferredValue", inp: 290.4, key: "l" }, { name: "startTransition", inp: 290.5, key: "l" },]; for (const page of pages) console.log(`${page.name}: ${page.inp.toFixed(1)} ms, on the ${page.key}`);- Lines 3 to 5 hold each page's slowest key press: the visit's INP.
- Line 8 prints
Plain update: 465.6 ms, on the b,useDeferredValue: 290.4 ms, on the landstartTransition: 290.5 ms, on the l.
The two React tools gave almost the same result. Both cut INP by more than a third. Both left it over 200 ms, for the reason in the last section. Why it matters: they work the same way, so pick by where your code is.
useDeferredValue or startTransition?
React's docs give the rule. startTransition needs the set function of the state. If you only get the value, from a prop or another hook, use useDeferredValue. Either way, the slow part waits, and React can drop work that is out of date.
Waiting is not debouncing
Lesson 28.1 debounced a search: it waited 250 ms after the last key. React's docs say a deferred value "doesn't require choosing any fixed delay". On a fast phone, the list comes almost at once. On a slow one, it lags further behind. Debouncing still helps work that isn't rendering, like a request to the server.
Now try it in your own browser. This playground runs the same kind of search with the React on this page:
// Plain update: the list gets the box's text at once.<Results query={text} /> // useDeferredValue: the list gets a copy that can lag behind.const deferredText = useDeferredValue(text);<Results query={deferredText} /> // startTransition: the list's query is set in a transition.startTransition(() => setQuery(text));<Results query={query} />Type to search 2,000 teas.
Type blend in the box at your normal speed. Then pick another way and type it again. Each key press slower than 16 ms is listed with its time.
With Plain update, type blend quickly. Watch the first key's time, and whether the keys after it wait. Then pick useDeferredValue or startTransition and type it again. React may put fewer lists on the page than you pressed keys: it skips lists that are out of date.
Run only what changed: memo and signals
Now Asha is on the All teas page, with all 3,000 rows. She clicks Add on Mint tea. Two things change on her screen: the cart says 1, and the button says Added. Here is the React page's list:
export function ListApp({ Row }) { const [cart, setCart] = useState([]); const [byPrice, setByPrice] = useState(false); const add = useCallback((name) => setCart((names) => [...names, name]), []); const teas = byPrice ? BY_PRICE : TEAS; return ( <Shop cart={cart.length} onSort={() => setByPrice(true)}> <ul id="teas" className="tea-list"> {teas.map((tea) => ( <Row key={tea.tea} tea={tea} added={cart.includes(tea.tea)} onAdd={add} /> ))} </ul> </Shop> );}- Line 2 keeps the cart: the teas Asha has added. Line 3 keeps whether the list is sorted by price.
- Line 4 makes
add, which puts a tea in the cart.useCallbackkeeps the same function from render to render. - Line 7 passes the cart's size to the header.
- Lines 9 to 11 draw a
Rowfor each tea.addedsays whether that tea is in the cart.
On the first page, Row is TeaRow. The click changes cart, so React renders ListApp again, and then every row inside it. Here is the same click on three pages:
// Asha clicks Add on Mint tea. Rows that ran again, the click's own work, the whole click (ms).const pages = [ { name: "React", rows: 3000, work: 43.5, click: 77.4 }, { name: "React with memo", rows: 1, work: 14.7, click: 48.3 }, { name: "Preact with signals", rows: 0, work: 2.5, click: 69.6 },]; for (const page of pages) console.log(`${page.name}: ${page.rows} of 3000 rows ran, work ${page.work.toFixed(1)} ms, click ${page.click.toFixed(1)} ms`);- Lines 3 to 5 hold the three pages.
rowsis how manyTeaRows ran.workis the click's processing: its handlers, with the framework's work.clickis the whole click, as INP counts it. - Line 8 prints one line per page. First,
React: 3000 of 3000 rows ran, work 43.5 ms, click 77.4 ms. - Then
React with memo: 1 of 3000 rows ran, work 14.7 ms, click 48.3 ms. - Last,
Preact with signals: 0 of 3000 rows ran, work 2.5 ms, click 69.6 ms.
memo: skip the rows that didn't change
The second page changes one line: const MemoRow = memo(TeaRow);, and passes MemoRow as the row. React then compares each row's props with the last ones. Only Mint tea's added changed, so only Mint tea's row ran. The click's work fell from 43.5 ms to 14.7 ms.
That's also why add uses useCallback: a new function on every render would be a changed prop. The React lesson memo, useMemo and useCallback explains this. React's docs add that the React Compiler applies the same skipping to every component for you.
Signals: tell only the code that read the value
The third page uses Preact, a small library much like React, with signals. A signal is a box for a value that remembers who read it. When the value changes, only the code that read it runs again. This is called fine-grained updating. SolidJS's docs put it plainly: Solid updates the one attribute that changed, while React "would re-execute an entire component".
A dry cleaner has 3,000 coats on its rails. When a coat is ready, the shop texts that coat's owner. It doesn't phone all 3,000 customers, one by one, to ask if their coat has changed.
- In real life: Each customer who left a coat
- In signals: Each piece of the page that read a signal
- In real life: A coat being ready
- In signals: A signal's value changing
- In real life: One text to one owner
- In signals: Updating Mint tea's button label
- In real life: Phoning all 3,000 customers to check
- In signals: React running all 3,000 rows
Here is a signal made small, so you can see how it remembers its readers:
let running = null;function signal(value) { const readers = new Set(); return { get value() { if (running) readers.add(running); return value; }, set value(next) { value = next; for (const reader of readers) reader(); }, };}function effect(fn) { running = fn; fn(); running = null; } let runs = 0;const teas = ["Mint tea", "Green tea", "Earl Grey"].map((name) => ({ name, added: signal(false) }));for (const tea of teas) effect(() => { runs += 1; tea.label = tea.added.value ? "Added" : "Add"; }); runs = 0;teas[0].added.value = true;console.log(`${teas[0].name}: ${teas[0].label}. Functions that ran: ${runs}`);- Lines 2 to 8 make a signal. Line 5 is its getter: whoever reads
valuewhile running is added toreaders. Line 6 is its setter: it changes the value, then runs each reader. - Line 9 runs a function once, marked as running, so the signals it reads remember it.
- Lines 12 and 13 make three teas, each with an
addedsignal, and one function per tea that sets its label. - Line 16 adds Mint tea. Line 17 prints
Mint tea: Added. Functions that ran: 1.
This is a model written for the lesson, much smaller than a real library. Press Edit & run and add a fourth tea to line 12. One function still runs.
Here is the real page's state, with Preact's signals:
const cart = signal(0);const rows = TEAS.map((tea) => { const added = signal(false); return { ...tea, added, label: computed(() => (added.value ? "Added" : "Add")) };}); function add(row) { batch(() => { row.added.value = true; cart.value += 1; });}- Line 1 is the cart count.
- Lines 2 to 5 give each tea its own
addedsignal, and alabelworked out from it withcomputed. - Lines 7 to 12 are
add.batchmakes both changes count as one update.
Each row's button shows {row.label}, and the header shows {cart}. Both pass the signal itself, not its value. Preact's docs say that lets Preact "skip all of the expensive rendering work". So no component ran. Preact changed two pieces of text, the same two React changed. Why it matters: the click's work fell from 43.5 ms to 2.5 ms.
Signals may one day be part of JavaScript itself. TC39, the group that designs the language, has a proposal for them at Stage 1, an early step. Its README says frameworks would still handle "scheduling rendering to DOM".
Why the signals click wasn't the quickest
Look at the whole clicks again. Signals did 2.5 ms of work, but the click took 69.6 ms. The memo click took 48.3 ms. Where did the time go? Into the next frame. The page still had 12,022 elements, so the browser's layout took 19.8 ms. And for 32.7 ms, the trace shows no work at all on the main thread.
Lesson 28.2 met a wait like that after its clicks. Here it came after the signals click in all three visits, and after none of the React clicks. Why it matters: signals made the code's share almost nothing. What's left is the browser's work on a big page.
Clicks before the page is ready
So far, every page was drawn in the browser. Lesson 23.3 drew the tea list on the server, then hydrated it: React ran the components again in the browser and attached their click handlers. It's lesson 23.3's shop that opens before the staff arrive. A click in that time goes one of two ways:
- Before React has the button, a click does nothing. Asha's first tap in lesson 23.3 was lost. Event Timing still timed it: 0 ms. To INP, a tap that does nothing is a perfect tap.
- While React is hydrating, a click waits for it. Her second tap took 152 ms: 5.8 ms of input delay, 51.3 ms of processing and 94.9 ms of presentation delay. React hydrated the page inside the tap, then ran her click. On the page drawn in the browser, the same tap took 120 ms.
React 18 added selective hydration. With Suspense boundaries, React hydrates the part you clicked first. The React 18 Working Group's notes say React will "synchronously hydrate" that part "during the capture phase of the click event". The rest waits. In lesson 23.3, the tap on that page took 144 ms. That lesson also showed islands and resumability, which hydrate less, or not at all. Why it matters: a page that looks ready can still lose clicks, or make them wait.
Keep only the rows on screen in the page
Back on the All teas page, Asha clicks Sort by price. On the React page, every row runs again. Here is that click, against a virtual list:
// Asha clicks Sort by price. Elements in the page, rows that ran, the whole click (ms).const pages = [ { name: "Every row in the page", elements: 12022, rows: 3000, click: 84.7 }, { name: "Virtual list", elements: 50, rows: 7, click: 17.0 },]; for (const page of pages) console.log(`${page.name}: ${page.elements} elements, ${page.rows} rows ran, click ${page.click.toFixed(1)} ms`);- Lines 3 and 4 hold both pages: the elements in the page, the rows that ran, and the whole click.
- Line 7 prints
Every row in the page: 12022 elements, 3000 rows ran, click 84.7 msandVirtual list: 50 elements, 7 rows ran, click 17.0 ms.
On the first page, React ran 3,000 rows, but it moved only 6 of them in the page. React's docs say a commit applies "the minimal necessary operations". That's lesson 28.2's "move only the rows that change", done for Ravi. Still, the click's processing was 45.6 ms: running 3,000 rows to find the 6.
The second page uses TanStack Virtual, a library for virtual lists. A virtual list keeps only the rows near the screen in the page, like lesson 28.2's windowed list. web.dev calls it "the concept of only rendering what is visible to the user". Here are the two parts that matter:
const virtualizer = useWindowVirtualizer({ count: teas.length, estimateSize: () => ROW_HEIGHT, scrollMargin: top, getItemKey: keyOf,}); {virtualizer.getVirtualItems().map((item) => { const tea = teas[item.index]; const style = { transform: `translateY(${item.start - top}px)` }; return <TeaRow key={item.key} tea={tea} added={cart.includes(tea.tea)} onAdd={add} style={style} />;})}- Lines 1 to 6 make the virtualizer.
countis how many teas there are.estimateSizegives each row's height: 52.4 px, a row and the gap under it.getItemKeygives each row its tea's own id as its key. - Line 8 asks for the virtual items: the rows on the screen, and one more below them.
- Lines 9 to 11 draw each one, moved down to its place in the full list.
A museum owns 3,000 paintings, but its one room has wall space for seven. To show them by date, the curator sorts her catalogue. Then she takes down seven paintings and hangs seven others. The rest stay in the store.
- In real life: The 3,000 paintings in the store
- In React: The 3,000 teas, kept as data
- In real life: The few on the wall
- In React: The rows in the page
- In real life: Sorting the catalogue by date
- In React: Sorting the data by price
- In real life: Rehanging the few on the wall
- In React: React rendering the rows near the screen
Sort by price changed the data. Then the virtualizer drew the rows near the screen again: 7 rows. The page had 50 elements in all, and the click took 17.0 ms. Why it matters: with few rows in the page, React and the browser both have little to redo.
What a virtual list costs
- Rows that aren't in the page aren't anywhere for the browser. Lesson 28.2 found that find-in-page can't find them, and the keyboard can't reach them.
- Fast scrolling can show blank space. web.dev warns that "blank space can briefly flash" before new rows are drawn. Extra rows above and below help. Too many slow the list down again.
- The library needs sizes. TanStack's docs say
estimateSizeshould give each row's real size, or an estimate if the rows are measured as they're drawn.
Chrome's guide to DOM size points React apps to windowing libraries like react-window, and to memo. On the search page, a virtual list would give the browser a handful of rows to style and lay out, not 2,993.
Compare all five clicks the course recorded:
- Input delay
4.3 msBefore the handlers started. - Processing
43.5 msThe handlers, with the framework's work for the click. - Presentation delay
29.6 msFrom the end of the handlers to the next frame.
3,000 of 3,000 rows ran again. The page had 12,022 elements, and the layout after the click took 17.9 ms.
Which fix for which slow interaction?
Read this table across. Each row is one fix: what it changes, and what the recording showed.
| The fix | What it changes | What the recording showed |
|---|---|---|
useDeferredValue | The box updates at once. The list renders afterwards, in small pieces, and lists that are out of date are skipped. | The b took 19.4 ms instead of 465.6 ms. The visit's INP fell from 465.6 ms to 290.4 ms. |
startTransition | The same, started from the handler. The box keeps its own state. | The visit's INP was 290.5 ms. |
memo on each row | React compares each row's props and skips the rows that didn't change. | 1 row ran instead of 3,000. The click's work fell from 43.5 ms to 14.7 ms. |
| Signals | Only the code that read the changed value runs again. | No row ran. The click's work was 2.5 ms. |
| A virtual list | Only the rows near the screen are in the page. | Sort by price took 17.0 ms instead of 84.7 ms. |
Now sort these changes. Does each one make the framework's work wait, run less code, or put fewer rows in the page?
- Pass
useDeferredValue(query)to the list - Set the list's query inside
startTransition - Wrap each row in
memo - Turn on the React Compiler
- Keep the cart count in a signal that only the header reads
- Render only the rows near the screen with TanStack Virtual
Sort each change by what it does for a slow key press or click.
Common mix-ups
- "useDeferredValue makes the slow list render faster." No. React's docs say it "does not make re-rendering" faster. It lets the list wait, so the box can show each key.
- "With a transition, no key press can wait." No. React can't pause while it changes the page, and the browser's layout runs in one go. The l still waited 235.8 ms.
- "Deferring is the same as debouncing." No. A deferred value has no fixed delay. React starts the list as soon as the urgent render is done.
- "When React re-renders, it rewrites the page." No. Sort by price ran 3,000 rows, but React moved only 6 of them in the page.
- "Signals make every click fast." No. The signals click did 2.5 ms of work, but the next frame still came 69.6 ms after the click.
- "A virtual list costs nothing." No. Rows that aren't in the page can't be found with find-in-page or reached by the keyboard.
Practice exercises
Answer first, then use the hints.
This program counts the rows React ran on the useDeferredValue page, from the lists it rendered. What does it print?
// How many teas matched each list React rendered on the useDeferredValue page.
const found = { b: 2993, blen: 2992, blend: 2992 };
let rows = 0;
for (const query in found) rows += found[query];
console.log(`TeaRow ran ${rows} times`);TeaRow ran 8977 times. React rendered three lists with useDeferredValue, against five on the plain page. The recording counted the same number of TeaRow calls.
The tiny signal again, now with a cart count that a header reads. Asha adds Earl Grey. What does it print?
let running = null;
function signal(value) {
const readers = new Set();
return {
get value() { if (running) readers.add(running); return value; },
set value(next) { value = next; for (const reader of readers) reader(); },
};
}
function effect(fn) { running = fn; fn(); running = null; }
let runs = 0;
let header = "";
const cart = signal(0);
const teas = ["Mint tea", "Green tea", "Earl Grey"].map((name) => ({ name, added: signal(false) }));
effect(() => { runs += 1; header = `Cart: ${cart.value}`; });
for (const tea of teas) effect(() => { runs += 1; tea.label = tea.added.value ? "Added" : "Add"; });
runs = 0;
teas[2].added.value = true;
cart.value += 1;
console.log(`${teas[2].name}: ${teas[2].label}. Functions that ran: ${runs}`);Earl Grey: Added. Functions that ran: 2. Earl Grey's label function read its added signal, and the header function read cart. The other two teas' functions didn't run.
In the key presses playground, pick startTransition. How long did the d take, in ms?
57.7 ms. By the d, React's work for the b was long done, so the d only waited a little.
In the five-clicks playground, pick React with memo: Add. What was the presentation delay, in ms?
28.7 ms. memo cut the work before the frame. The frame itself, with layout for a page of 12,022 elements, cost about the same as before.
Open the course's trace of the plain search in Chrome's DevTools. In the Layout right after Asha's first key, how many nodes needed layout? Give the first number.
20,959 of 20,973. Nearly every box on the page needed layout, because 2,993 rows had just been added. That is the 138.3 ms of layout the b paid for.
In React's Scheduler.js, what does the scheduler use in browsers to run its next piece of work, instead of setTimeout?
MessageChannel. The scheduler posts a message to itself, and its next piece runs when the message arrives. The comment says it prefers this because setTimeout is clamped to at least 4 ms.
Check your understanding
For each question, ask whose work it is: React's, or the browser's, and whether it can wait.
Question 1 of 7On the plain search page, what made Asha's first key press the slowest?
Choose an answer to see the explanation.
Question 2 of 7What does
useDeferredValue(query)change on the search page?Choose an answer to see the explanation.
Question 3 of 7With
useDeferredValue, why did the l still wait so long?Choose an answer to see the explanation.
Question 4 of 7Asha clicks Add on Mint tea. What does wrapping each row in
memochange?Choose an answer to see the explanation.
Question 5 of 7On the signals page, Mint tea's
addedsignal changes. What runs again?Choose an answer to see the explanation.
Question 6 of 7A visitor taps a button before hydration has reached it. Which of these can happen?
Choose an answer to see the explanation.
Question 7 of 7What does a virtual list give up for its speed?
Choose an answer to see the explanation.
Key takeaways
- A framework's share of a key press or click comes in two parts: its own render and commit, then the browser's style and layout.
- A plain state update renders at once. On the plain search, the b took 465.6 ms, and nothing new showed for 467.4 ms.
- useDeferredValue and startTransition let the list wait. React renders it in pieces of about 5 ms and skips lists that are out of date.
- They can't split React's commit or the browser's layout. The l still waited 235.8 ms, and INP was 290.4 ms.
- memo and signals run only what changed: 1 row with memo, none with signals, instead of 3,000.
- A click during hydration can be lost, or wait for hydration. Selective hydration does the clicked part first.
- A virtual list keeps a few rows in the page. Sort by price took 17.0 ms instead of 84.7 ms.
Remember the one-liner.
A framework turns each change into work: let the slow part wait, run only what changed, and keep few rows in the page.
Sources
The facts on this page come from these docs, posts and source files:
- Interaction to Next Paint (INP), web.dev.
- Render and Commit, React docs.
- useDeferredValue, React docs.
- React v18.0, React blog.
- SchedulerFeatureFlags.js, React 19.3.1, React on GitHub.
- Scheduler.js, React 19.3.1, React on GitHub.
- useTransition, React docs.
- startTransition, React docs.
- memo, React docs.
- JavaScript Signals standard proposal, TC39 on GitHub.
- Signals, Preact docs.
- Fine-grained reactivity, SolidJS docs.
- New Suspense SSR Architecture in React 18, React 18 Working Group.
- Virtualizer, TanStack Virtual docs.
- Virtualize large lists with react-window, web.dev.
- Optimize DOM size, Chrome for Developers.
- Client-side rendering of HTML and interactivity, web.dev.
Coming next: INP fixes from real sites. Real teams' slow interactions, and the fixes they had in common.