completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

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.

By the end, you can
  • 01
    Read 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.
  • 02
    Let the slow part waitKeep a search box quick with useDeferredValue or startTransition, and know what they can't split up.
  • 03
    Do 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.

Real frames of the search page as Asha typed blend. Plain update: an empty box at her first key, then nothing new until 467.4 ms, when the box showed ble with 2992 teas, then blend at 532.9 ms. With useDeferredValue: the b at 21.5 ms with no teas yet, then ble with the teas for b at 412.3 ms, then blend with its teas at 576.8 ms.Plainfirst keyThe search box, empty, over the words Type to search 3,000 teasempty box467.4 msThe box says ble, and 2992 teas match bleble, 2992 teas532.9 msThe box says blend, and 2992 teas match blendblend, 2992 teasDeferred21.5 msThe box says b, and the list is still emptyb, no teas yet412.3 msThe box says ble, and the list shows 2993 teas for bble, the teas for b576.8 msThe box says blend, and 2992 teas match blendblend, its teas
Real frames from the course's recording on Asha's phone, in ms after her first key. She pressed a key every 120 ms. On the plain page, nothing new showed for 467.4 ms. With useDeferredValue, the b showed at once, and the list came later.

Here are the same moments as code:

When Asha first saw something newPop out in the code editor (opens in a new tab)JavaScript
// 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. firstFrame is the first frame that showed anything new. showed is 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.

Real-life analogyA librarian who lays out every card before listening again

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
The main idea

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.

search-plain.jsx: the searchJSX
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. setQuery changes it.
  • Line 5 draws the box. Each key calls setQuery with 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:

Results.jsx: the teas that matchJSX
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 TeaRow for 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 b on the plain pagePop out in the code editor (opens in a new tab)JavaScript
// 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. react is React's render and commit. style and layout are the browser's work on the new rows.
  • Lines 4 and 5 print React built and placed 2993 rows: 141.7 ms and The browser worked out their styles: 33.0 ms.
  • Lines 6 and 7 print The browser laid them out: 138.3 ms and Together: 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:

Five key presses on the plain pagePop out in the code editor (opens in a new tab)JavaScript
// 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. pressed is when the key went down. waited is how long it waited before its handlers could start. That wait is its input delay. took is 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, then l: 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:

search-deferred.jsx: the searchJSX
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.

Real-life analogyNotes now, the clean copy later

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 typedPop out in the code editor (opens in a new tab)JavaScript
// 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, blend and useDeferredValue: 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:

Five key presses with useDeferredValuePop out in the code editor (opens in a new tab)JavaScript
// 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 prints l: 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:

The main thread, from Asha's first key until her second key's handlers could start. Plain update: React built the rows from 3.8 ms to 145.5 ms, then the browser's style and layout until 316.8 ms. The l, pressed at 120.5 ms, started at 318.2 ms. useDeferredValue: the b, then 13 pieces of React's work, a last piece from 90.8 ms to 181.0 ms, then style and layout until 355.3 ms. The l, pressed at 120.2 ms, started at 356.0 ms.PlainReact builds rowsstyle + layoutl pressedl startsDeferred13 pieceslast piecestyle + layoutl pressedl startsb on screen at 21.5 ms0 ms100 ms200 ms300 ms
From Chrome's trace, to scale, in ms after the b. With useDeferredValue, React split its work into pieces. Its last piece, and the browser's style and layout, each ran in one go, and the l key waited behind them.
What the l waited forPop out in the code editor (opens in a new tab)JavaScript
// 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 ms and The 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.

Playground: Asha's five key presses, three ways
Each keyPlain update
  1. b, pressed at 0.0 mstook 465.6 mswaited 0.3 ms; the letter showed at 465.6 ms; the list for "b" never showed
  2. l, pressed at 120.5 mstook 345.1 mswaited 197.7 ms; the letter showed at 465.5 ms; the list for "bl" never showed
  3. e, pressed at 240.3 mstook 225.3 mswaited 146.0 ms; the letter showed at 465.6 ms; the list for "ble" showed at 467.4 ms
  4. n, pressed at 361.9 mstook 168.6 mswaited 96.9 ms; the letter showed at 530.5 ms; the list for "blen" never showed
  5. d, pressed at 480.5 mstook 50.0 mswaited 11.5 ms; the letter showed at 530.6 ms; the list for "blend" showed at 532.9 ms
Try it yourself
Plain update: the slowest key took 465.6 ms, the b

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.

From the course's recording in Chrome 157, with the CPU slowed 4 times: the middle of three visits for each page. Times are in ms after Asha's first key.

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.

search-transition.jsx: the searchJSX
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: text for the box, and query for the list.
  • Line 5 sets text at once. That's an urgent update, so the box shows the key.
  • Line 6 sets query inside startTransition. React marks that update as a transition.
  • Lines 10 and 11 give the box text, and the list query.

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 pagePop out in the code editor (opens in a new tab)JavaScript
// 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 l and startTransition: 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:

Playground: type in a React search, three ways
What each way does, shortenedJSX
// 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} />
Plain update0 timed

Type to search 2,000 teas.

      Try it yourself
      Which way keeps up with you?

      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.

      Real key presses in your own browser, with the React on this page, timed with Event Timing and rounded to 8 ms. The list has 2,000 rows and starts empty. Numbers change from run to run, and with your computer's speed.

      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:

      ListApp.jsx: the All teas list in ReactJSX
      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. useCallback keeps the same function from render to render.
      • Line 7 passes the cart's size to the header.
      • Lines 9 to 11 draw a Row for each tea. added says 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:

      Add on Mint tea, three waysPop out in the code editor (opens in a new tab)JavaScript
      // 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. rows is how many TeaRows ran. work is the click's processing: its handlers, with the framework's work. click is 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.
      What ran when Asha clicked Add on Mint tea. React: ListApp, then TeaRow for all 3,000 rows, 43.5 ms of work. React with memo: ListApp, then a props check for each row, and TeaRow for 1 row, 14.7 ms. Preact with signals: no component ran; two texts changed, 1 and Added, 2.5 ms.Reactwork: 43.5 msReact with memowork: 14.7 msPreact with signalswork: 2.5 msListAppTeaRow × 3,000every row ranListAppcheck × 3,0001 row ranCart: 1Addedno component ran
      The same two texts changed on all three pages: the cart count and Mint tea's button. Signals changed them without running any component.

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

      Real-life analogyThe dry cleaner texts only the owner

      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:

      A tiny signal, written for this lessonPop out in the code editor (opens in a new tab)JavaScript
      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 value while running is added to readers. 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 added signal, 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:

      The signals page: one signal for the cart, one for each teaJSX
      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 added signal, and a label worked out from it with computed.
      • Lines 7 to 12 are add. batch makes 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:

      Sort by price, two waysPop out in the code editor (opens in a new tab)JavaScript
      // 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 ms and Virtual list: 50 elements, 7 rows ran, click 17.0 ms.
      Real frames of Asha's screen when she clicked Sort by price. With every row in the page, 3,000 rows ran and the sorted list showed at 85.5 ms. With a virtual list, 7 rows ran and the sorted list showed at 17.7 ms. Both screens look the same.Every rowher clickThe tea list in the shop's order: Mint tea, Green tea, Earl Grey, Chamomileshop order3,000 rows ran12,022 elements85.5 msThe tea list sorted by price: Chamomile, Rooibos, Earl Grey, Green teasorted by priceVirtual listher clickThe tea list in the shop's order: Mint tea, Green tea, Earl Grey, Chamomileshop order7 rows ran50 elements17.7 msThe tea list sorted by price: Chamomile, Rooibos, Earl Grey, Green teasorted by price
      Real frames, in ms after Asha's click. The two pages look the same. The virtual list had 7 rows in the page instead of 3,000.

      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:

      list-virtual.jsx: the virtualizer and its rows, shortenedJSX
      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. count is how many teas there are. estimateSize gives each row's height: 52.4 px, a row and the gap under it. getItemKey gives 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.
      Real-life analogyA gallery with room for seven paintings

      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 estimateSize should 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:

      Playground: five clicks on the tea list
      The click's three partsAdd on Mint tea
      1. Input delay4.3 msBefore the handlers started.
      2. Processing43.5 msThe handlers, with the framework's work for the click.
      3. Presentation delay29.6 msFrom the end of the handlers to the next frame.
      Try it yourself
      React, Add on Mint tea: the click took 77.4 ms

      3,000 of 3,000 rows ran again. The page had 12,022 elements, and the layout after the click took 17.9 ms.

      From the course's recording in Chrome 157, with the CPU slowed 4 times: the middle of three clicks for each page. The three parts come from Chrome's trace.

      Which fix for which slow interaction?

      Read this table across. Each row is one fix: what it changes, and what the recording showed.

      Five ways to make a framework's interactions quicker
      The fixWhat it changesWhat the recording showed
      useDeferredValueThe 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.
      startTransitionThe same, started from the handler. The box keeps its own state.The visit's INP was 290.5 ms.
      memo on each rowReact 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.
      SignalsOnly the code that read the changed value runs again.No row ran. The click's work was 2.5 ms.
      A virtual listOnly 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?

      Wait, less code, or fewer rows?
      • 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
      Try it yourself
      0 of 6 correct

      Sort each change by what it does for a slow key press or click.

      Choose a category for every card. You can change an answer at any time; Reset clears them all.

      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.

      Exercise 1 · Warm-upHow many rows ran?

      This program counts the rows React ran on the useDeferredValue page, from the lists it rendered. What does it print?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      // 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`);

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 2 · Warm-upWhich functions run?

        The tiny signal again, now with a cart count that a header reads. Asha adds Earl Grey. What does it print?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        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}`);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 3 · PracticeThe d with startTransition

          In the key presses playground, pick startTransition. How long did the d take, in ms?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 4 · PracticePresentation delay with memo

            In the five-clicks playground, pick React with memo: Add. What was the presentation delay, in ms?

            Answer, then press Check. Spacing and letter case don’t matter.

              Exercise 5 · ChallengeLayout in DevTools

              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.

              Answer, then press Check. Spacing and letter case don’t matter.

                Exercise 6 · ChallengeHow the scheduler hands back the main thread

                In React's Scheduler.js, what does the scheduler use in browsers to run its next piece of work, instead of setTimeout?

                Answer, then press Check. Spacing and letter case don’t matter.

                  Check your understanding

                  For each question, ask whose work it is: React's, or the browser's, and whether it can wait.

                  The INP in frameworks quiz · 7 questionsScore: first tries count
                  1. Question 1 of 7On the plain search page, what made Asha's first key press the slowest?

                    Choose an answer to see the explanation.

                  2. Question 2 of 7What does useDeferredValue(query) change on the search page?

                    Choose an answer to see the explanation.

                  3. Question 3 of 7With useDeferredValue, why did the l still wait so long?

                    Choose an answer to see the explanation.

                  4. Question 4 of 7Asha clicks Add on Mint tea. What does wrapping each row in memo change?

                    Choose an answer to see the explanation.

                  5. Question 5 of 7On the signals page, Mint tea's added signal changes. What runs again?

                    Choose an answer to see the explanation.

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

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

                  Coming next: INP fixes from real sites. Real teams' slow interactions, and the fixes they had in common.

                  CompleteFrontend Clear concepts. Working examples.