Performance patterns
Speed up React without memo: move state down, pass slow parts as children, virtualize long lists, split code with lazy, and keep heavy work out of render.
- 01Stop renders before they startMove state into the component that uses it, and pass slow content as
children, so a change doesn't render parts that don't need it. - 02Render and load lessBuild a small windowing component for a long list, know when to use react-window or TanStack Virtual, and load rarely used code with
lazy. - 03Keep slow work off the render pathMove work to the top of the file, into a state initializer,
useMemo, or an event handler, and prove each fix with a render or work counter.
Fix the structure before you reach for memo
In Profiling React apps, a profile pointed at 15 picks that rendered on every key, and memo skipped them. memo works, but it's often not the best fix. Most slow renders come from where state lives and what a component renders, and a small change to the structure stops the work from starting at all. When React re-renders fixed a gift note that way; this lesson goes through each structural fix with a before and an after, and adds three more: long lists, code that loads later, and slow work inside a component. Here is a tea shop with a newsletter box under its grid of teas. The result is live: type your email in the box.
import { useState } from "react";import ProductGrid from "./ProductGrid.jsx"; export default function ShopPage() { const [email, setEmail] = useState(""); return ( <> <ProductGrid /> <footer> <label> Tea news: <input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" /> </label> <p>{email.includes("@") ? "Looks good." : "Type your email."}</p> </footer> </> );}Line 5 keeps the email in ShopPage. Line 8 draws ProductGrid, from the ProductGrid.jsx tab: 12 teas, each card slow on purpose (lines 7 to 10 of that tab wait 3 ms). Lines 9 to 14 are the newsletter footer, and line 13 checks the email as you type. The grid's heading has a render counter from the RenderCount.jsx tab, which counts the renders React put on the page.
Type asha: the counter climbs to renders: 5, one for the first render and one per key, and the letters lag a little. The grid doesn't use the email at all. It renders because it's inside the component whose state changed. Here is the rule that every pattern in this lesson works with:
- A state change renders the component that holds the state and everything it returns, all the way down.
- Components above it and beside it don't render.
- React skips a component only when it can tell nothing changed, like an element that is the same object as last time.
email lives in ShopPage, so every key renders the whole page. Right: it lives in Newsletter, so a key renders only that.A shop manager wants the tea counter to refill the green tea. She could say it over the loudspeaker, and every worker in the shop would stop and listen, even the cashiers. Or she could hand a note to the one person at the tea counter. Same message, much less disruption. A parcel for the back room passes through the front desk unopened, so the front desk doesn't have to deal with what's inside.
- In real life: An announcement over the loudspeaker
- In React: State at the top of the tree
- In real life: Every worker in the shop stops to listen
- In React: Every component below it renders
- In real life: A note handed to the one worker it's for
- In React: State in the component that uses it
- In real life: A sealed parcel passed along unopened
- In React: An element passed as children
- In real life: Only stocking the shelves people can see
- In React: Rendering only the rows on screen
Performance patterns change what React has to do, not how fast it does it. Keep state where it's used, pass slow content through as children, render only the rows on screen, load code only when it's needed, and keep slow calculations out of rendering. Measure first, so you use the pattern that fits.
Move state down to the part that uses it
Only the newsletter footer uses email. So give the footer a component of its own, and let that component hold the state. Type asha again:
import { useState } from "react";import ProductGrid from "./ProductGrid.jsx"; function Newsletter() { const [email, setEmail] = useState(""); return ( <footer> <label> Tea news: <input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" /> </label> <p>{email.includes("@") ? "Looks good." : "Type your email."}</p> </footer> );} export default function ShopPage() { return ( <> <ProductGrid /> <Newsletter /> </> );}Lines 4 to 14 are Newsletter. Line 5 moved here from ShopPage, with the footer it controls. Now ShopPage (lines 16 to 23) has no state at all: it draws the grid on line 19 and the newsletter on line 20. Each key renders Newsletter only, so the grid's counter stays at renders: 1, and the letters keep up.
This is the opposite of lifting state up. You lift state when two components need it; you keep it down when only one does. react.dev gives the same advice: don't lift state higher than it needs to go. Text you're typing, whether a menu is open, which card the mouse is over: these belong in the component that shows them, not at the top of the page or in a global store.
The React Compiler would skip the grid here too: it remembers the <ProductGrid /> element when nothing it depends on changed. Moving state down is still worth doing. It keeps work from starting, it works without a compiler, and the code reads better: the state sits next to the only thing that uses it.
Pass slow parts in as children
Sometimes the state can't move down, because the component that holds it wraps the slow part. Here a TextSizer lets the reader make the tea list bigger or smaller. Its font size has to sit on a <div> around the grid. Click Larger twice:
import { useState } from "react";import ProductGrid from "./ProductGrid.jsx"; function TextSizer() { const [size, setSize] = useState(16); return ( <div style={{ fontSize: size }}> <button onClick={() => setSize(size - 2)}>Smaller</button> <button onClick={() => setSize(size + 2)}>Larger</button> <ProductGrid /> </div> );} export default function ShopPage() { return <TextSizer />;}Line 5 keeps the size, and line 7 puts it on the <div>. Lines 8 and 9 change it. Line 10 draws ProductGrid inside. Each click renders TextSizer, and with it the grid: the counter says renders: 3, though the grid's output never changed. Only the CSS font size did.
Now let TextSizer take whatever it wraps as children, and let ShopPage write the grid:
import { useState } from "react";import ProductGrid from "./ProductGrid.jsx"; function TextSizer({ children }) { const [size, setSize] = useState(16); return ( <div style={{ fontSize: size }}> <button onClick={() => setSize(size - 2)}>Smaller</button> <button onClick={() => setSize(size + 2)}>Larger</button> {children} </div> );} export default function ShopPage() { return ( <TextSizer> <ProductGrid /> </TextSizer> );}Line 4 takes children, and line 10 places them. Lines 17 to 19 in ShopPage write <ProductGrid /> between the TextSizer tags. Click Larger twice: the text grows, and the counter stays at renders: 1.
Here's why. <ProductGrid /> is JSX, and JSX makes a plain object, an element, when the component that writes it renders. In the first version, TextSizer wrote it, so every click made a new element, and React rendered the grid. In the second, ShopPage wrote it, and ShopPage doesn't render when TextSizer's state changes. So children is the very same object as last time, and React skips everything inside it. The Context lesson used the same move to avoid passing props through layers.
TextSizer renders in both. Left: its own JSX makes a new <ProductGrid />, so the grid renders. Right: the element came from ShopPage, which didn't render, so it's the same object as last time, and React skips the grid.Look for components that only wrap others: layouts, panels, frames, accordions, a component that tracks scrolling or the window's size. If they hold state and draw their contents themselves, let the parent pass the contents in instead.
Render only the rows on screen
Some pages are slow because they render a lot, not because they render too often. This order history has 5,000 orders, from the orders.js tab. Watch how long the result takes to appear, then type chai in the box:
import { useState } from "react";import { orders } from "./orders.js"; export default function OrderHistory() { const [query, setQuery] = useState(""); const matches = orders.filter((order) => order.name.toLowerCase().includes(query.toLowerCase())); return ( <> <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Filter orders" /> <p>Rows in the page: {matches.length} of {matches.length}</p> <ul className="orders"> {matches.map((order) => ( <li key={order.id}>#{order.id} {order.name}</li> ))} </ul> </> );}Line 6 filters the orders, and lines 12 to 14 make a <li> for every match. The list scrolls inside a box 240 pixels tall (the styles.css tab), so only about 8 rows are ever on screen, yet line 10 says Rows in the page: 5000 of 5000. React creates all 5,000 elements, the browser lays them all out, and each key renders and compares every one again.
Virtualizing (also called windowing) means rendering only the rows that are on screen, plus a few around them, and nothing else. Here is a small virtualizer, in the VirtualList.jsx tab, and the same order history using it:
import { useState } from "react";import { orders } from "./orders.js";import VirtualList from "./VirtualList.jsx"; export default function OrderHistory() { const [query, setQuery] = useState(""); const matches = orders.filter((order) => order.name.toLowerCase().includes(query.toLowerCase())); return ( <> <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Filter orders" /> <VirtualList items={matches} rowHeight={30} height={240} renderRow={(order) => `#${order.id} ${order.name}`} /> </> );}Lines 11 to 16 hand VirtualList the matches, the height of one row, the height of the box, and a function that draws one row. The result says Rows in the page: 10 of 5000. Scroll the list: the count stays around 12, and the row numbers change. Here is how the VirtualList.jsx tab does it:
- Line 4 keeps how far the box is scrolled,
scrollTop, in state. Line 21 updates it on every scroll. - Line 5 works out the first row on screen: 3,000 pixels down with 30-pixel rows is row 100 (counting from 0).
- Lines 6 and 7 add 2 extra rows above and below, so a short scroll already has rows to show, and stay inside the list.
- Lines 10 to 16 make only those rows. Line 12 places each one exactly where it would be in the full list, with
position: absoluteandtop. - Line 22 makes the
<ul>as tall as all the rows together, 150,000 pixels, so the scroll bar has the right size and position.
<ul> is as tall as all 5,000 rows, so the scroll bar is right, but only 12 rows are in the page: the 8 on screen and 2 more on each side, ready for the next scroll.The playground runs the same windowing code on this page. Scroll the list or drag the slider, and switch how many extra rows it adds. The chips show which rows are on screen, which are in the page but off screen, and which aren't in the page at all.
rows 1–10ul: 150,000 px- on screen
- in the page, off screen
- not in the page
- #1
- #2
- #3
- #4
- #5
- #6
- #7
- #8
- #9
- #10
- #11
- #12
- #13
Scrolled 0 px, so the first row on screen is #1. React put rows #1 to #10 in the page: 10 rows, the 8 that fit and 2 extra, so a short scroll already has rows to show. The other 4990 rows aren't in the page at all.
With No extra rows, scroll slowly and you'll see an empty strip at the bottom of the box whenever a row is only partly on screen. That's what the extra rows are for. A few are enough; many more only add work back.
This virtualizer is small on purpose. It needs every row to be the same height, it only scrolls up and down, and it does nothing for keyboard users or screen readers. For a real app, use a library (versions checked in October 2026). react-window 2 renders lists and grids with List and Grid; TanStack Virtual 3 gives you a Hook, useVirtualizer, that works out which rows to draw and leaves the markup to you, and it can measure rows of different heights. Here is the order list with react-window. It isn't live, because the results on this page can only import React:
import { List } from "react-window";
function OrderRow({ index, style, ariaAttributes, orders }) {
return (
<div style={style} {...ariaAttributes}>
#{orders[index].id} {orders[index].name}
</div>
);
}
export default function OrderHistory({ orders }) {
return (
<List
rowComponent={OrderRow}
rowCount={orders.length}
rowHeight={30}
rowProps={{ orders }}
style={{ height: 240 }}
/>
);
}Lines 13 to 19 render a List: rowComponent is the component for one row, rowCount how many rows there are, rowHeight their height in pixels, and rowProps extra props for every row. Lines 3 to 9 are that row component. React-window passes it index, a style that places it, and ariaAttributes that tell screen readers the row's position in the whole list; line 5 spreads both onto the row.
One thing to know before you virtualize: rows that aren't in the page can't be found with the browser's find-in-page, and assistive tools only see the rows that are there. For a list of a few hundred simple rows, try the CSS property content-visibility: auto first: every row stays in the page, but the browser skips laying out and painting the ones that are off screen. React still renders all of them, though.
Load rarely used code later with lazy
Some slowness happens before React renders anything: the browser has to download and run your JavaScript first. Every import at the top of a file is part of that first download, even code few readers ever use. In this product page, open the console under the result before you click anything:
import { useState } from "react";import ReviewsChart from "./ReviewsChart.jsx"; export default function ProductPage() { const [showChart, setShowChart] = useState(false); return ( <> <h3>Green tea</h3> <button onClick={() => setShowChart(true)}>Show the ratings chart</button> {showChart && <ReviewsChart />} </> );}Line 2 imports ReviewsChart, and line 10 shows it after a click. The console already says ReviewsChart.jsx runs (line 2 of the ReviewsChart.jsx tab): the chart's code was loaded and run with the page, though you haven't asked for it. In a real app, a chart like this brings a charting library with it, and every reader pays for it on the first load.
The Suspense lesson introduced lazy: it loads a component's code the first time the component renders, using dynamic import(). Here it is for the chart. A pretend download makes the file take a second, so you can see the wait:
import { Suspense, lazy, useState } from "react"; const ReviewsChart = lazy(() => download(import("./ReviewsChart.jsx"))); // Pretends the file takes a second to download.function download(file) { console.log("Downloading ReviewsChart.jsx"); return new Promise((resolve) => setTimeout(() => resolve(file), 1000));} export default function ProductPage() { const [showChart, setShowChart] = useState(false); return ( <> <h3>Green tea</h3> <button onClick={() => setShowChart(true)}>Show the ratings chart</button> {showChart && ( <Suspense fallback={<p>Loading the chart…</p>}> <ReviewsChart /> </Suspense> )} </> );}Line 3 declares the lazy component at the top level of the file. Lines 6 to 9 pretend the download takes a second. Lines 18 to 20 wrap the chart in a Suspense boundary, which shows Loading the chart… while it downloads. Press Run again: the console stays empty until you click. Then it says Downloading ReviewsChart.jsx, and a second later the chart appears. A bundler like Vite puts the chart in a file of its own, so the first download is smaller for everyone.
The reader still waits a second after the click. You can start the download earlier, when the reader shows they're about to click: on hover, or when the button gets keyboard focus:
import { Suspense, lazy, useState } from "react"; let chartDownload = null;function loadChart() { chartDownload ??= download(import("./ReviewsChart.jsx")); return chartDownload;}const ReviewsChart = lazy(loadChart); // Pretends the file takes a second to download.function download(file) { console.log("Downloading ReviewsChart.jsx"); return new Promise((resolve) => setTimeout(() => resolve(file), 1000));} export default function ProductPage() { const [showChart, setShowChart] = useState(false); return ( <> <h3>Green tea</h3> <button onPointerEnter={loadChart} onFocus={loadChart} onClick={() => setShowChart(true)}> Show the ratings chart </button> {showChart && ( <Suspense fallback={<p>Loading the chart…</p>}> <ReviewsChart /> </Suspense> )} </> );}Lines 4 to 7 are loadChart. It starts the download once and keeps the promise in chartDownload, so calling it again returns the same one. Line 8 gives lazy that same function. Line 21 calls it when the pointer enters the button and when the button gets focus. Move the mouse over the button, wait a second, and click: the console says Downloading ReviewsChart.jsx only once, when you hovered, and the chart appears after about a third of a second instead of a whole second. That short wait is React's own: it reveals suspended content at most once every 300 ms, so Loading the chart… flashes briefly. (A real import() of the same file also gives the same module every time; chartDownload is only needed here to make the pretend wait happen once.)
import, the chart's code comes with the page, even for readers who never open it. Right: with lazy and import(), the first load is smaller, and the chart's file comes when it's needed.What's worth splitting: whole pages (routers and frameworks like Next.js do this for every route), and big parts that few readers open, like an editor, a chart, a map, or a dialog. What isn't: small components and anything on screen when the page loads. Each split is one more request and one more loading state. To see what's big, look at the file sizes your build prints, or at the Network panel in the browser's developer tools, as Loading performance shows.
Keep expensive work out of rendering
Everything in a component's body runs on every render. Usually that's fine. But slow work that doesn't need to run again shouldn't sit there. This search builds an index of 5,000 products, from the products.js tab, so it can look up a word at once. Open the console, then type green:
import { useState } from "react";import { products } from "./products.js"; function buildSearchIndex(list) { console.log("Building the search index"); const index = new Map(); for (const product of list) { for (const word of product.name.toLowerCase().split(" ")) { if (!index.has(word)) index.set(word, []); index.get(word).push(product); } } return index;} export default function ProductSearch() { const [index] = useState(buildSearchIndex(products)); const [word, setWord] = useState(""); const matches = index.get(word.toLowerCase()) ?? []; return ( <> <input value={word} onChange={(e) => setWord(e.target.value)} placeholder="One word, like green" /> <p>{matches.length} matches</p> </> );}Lines 4 to 14 build the index: a Map from each word to the products that have it. Line 5 logs each time it runs, a small work counter. Line 17 keeps the index in state. The console shows Building the search index twice when the page loads (StrictMode calls the component twice in development), then two more for every key: 12 lines in all after green, for an index that never changes.
The reason is JavaScript, not React. useState(buildSearchIndex(products)) calls buildSearchIndex first, to get the argument, and only then calls useState, which ignores it after the first render. Pass the function instead, and React calls it only on the first render:
import { useState } from "react";import { products } from "./products.js"; function buildSearchIndex(list) { console.log("Building the search index"); const index = new Map(); for (const product of list) { for (const word of product.name.toLowerCase().split(" ")) { if (!index.has(word)) index.set(word, []); index.get(word).push(product); } } return index;} export default function ProductSearch() { const [index] = useState(() => buildSearchIndex(products)); const [word, setWord] = useState(""); const matches = index.get(word.toLowerCase()) ?? []; return ( <> <input value={word} onChange={(e) => setWord(e.target.value)} placeholder="One word, like green" /> <p>{matches.length} matches</p> </> );}The only change is on line 17: useState(() => buildSearchIndex(products)). React calls a function it gets as the starting value only on the first render; it's called an initializer. The console shows the line twice when the page loads (StrictMode calls initializers twice too, to help you find impure ones) and never again while you type. Here, products never changes, so the index could even move to the top of the file, outside the component: press Edit & run, write const index = buildSearchIndex(products); after the function, and delete line 17. The console shows the line once, when the file loads.
How to read the table: each row is a kind of slow work, and where it should go.
| The work | Where it goes |
|---|---|
| Builds something once, from data that never changes | At the top of the file, outside the component. It runs once, when the file loads. |
| Builds the starting value of a piece of state | In a function you pass to useState: useState(() => build()). React calls it on the first render only. |
| Works out a value from props or state, and is slow | In useMemo, or let the React Compiler remember it. It runs again only when its inputs change. |
| Is only needed after a click, like building a file to download | In the event handler. It runs when the reader clicks, never while rendering. |
| Copies props or state into more state | Nowhere. Work it out while rendering, as You might not need an Effect shows. |
Two rows point at other lessons. useMemo remembers a calculation between renders and runs it again only when its inputs change; memo, useMemo and useCallback covers it, and The React Compiler adds the same remembering for you. And state copied from other state, through an Effect, is the cascading update from the profiling lesson: You might not need an Effect shows how to remove it.
A catalog that uses every pattern
Here the patterns work together in one catalog of 5,000 teas. Type in the search box, scroll the list, and open the ratings:
import { Suspense, lazy, useState } from "react";import { products } from "./products.js";import VirtualList from "./VirtualList.jsx"; const ReviewsChart = lazy(() => import("./ReviewsChart.jsx")); function CatalogSearch() { const [query, setQuery] = useState(""); const matches = products.filter((product) => product.name.toLowerCase().includes(query.toLowerCase())); return ( <> <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search 5,000 teas" /> <VirtualList items={matches} rowHeight={30} height={180} renderRow={(product) => product.name} /> </> );} function Reviews() { const [open, setOpen] = useState(false); return ( <section> <button onClick={() => setOpen(!open)}>{open ? "Hide" : "Show"} the ratings</button> {open && ( <Suspense fallback={<p>Loading the chart…</p>}> <ReviewsChart /> </Suspense> )} </section> );} export default function Catalog() { return ( <> <h3>Tea catalog</h3> <CatalogSearch /> <Reviews /> </> );}Each piece of state lives with the part that uses it: the query in CatalogSearch (line 8), whether the ratings are open in Reviews (line 19). Typing never renders the ratings, and opening them never renders the list. Catalog (lines 32 to 40) has no state, so it renders once. Line 13 virtualizes the matches with the same VirtualList.jsx tab, so typing renders about 8 rows, not 5,000. Line 5 loads the chart's code the first time the ratings open; the console says ReviewsChart.jsx runs only then.
How to read the table: each row is one pattern, when it fits, and what it costs.
| Pattern | Use it when | What it costs |
|---|---|---|
| Move state down | Only one part of the page reads the state. | Nothing. The code often gets simpler. |
| Pass content as children | A component with state wraps content that doesn't use that state. | The wrapper can't pass props into that content; the parent writes it. |
| Virtualize a list | A list has hundreds or thousands of rows. | Rows need a known or estimated height, and find-in-page only sees rows in the page. |
| Split code with lazy | A big part of the page that few readers open. | One more request, and a loading state the first time it opens. |
| Move work out of rendering | A slow calculation runs on renders that don't need it. | Nothing, or a few more lines. |
- A
hoveredIdstate at the top of the page renders every product card when the mouse moves - Typing in a coupon box at the bottom of checkout renders the whole order summary
- An
Accordionwith an open/closed state draws a slow product table inside it - A
ResizablePanelkeeps its width in state and wraps a slow chart that doesn't use the width - An order history with 20,000 rows takes a second to appear
- A chat log with thousands of messages makes scrolling and typing slow
- A rich text editor library adds 300 KB to every page, but only the admin page uses it
- A big "Compare plans" dialog that one reader in twenty opens
Sort each slow page by the pattern that fixes it best.
Common mix-ups
- "A slow page needs
memo." No. Often the state just lives too high, or a wrapper draws its contents itself. Fixing the structure stops the work from starting, with nothing to compare on each render. - "When a parent renders, its
childrenalways render too." No. Elements passed in aschildrenwere made by the component above. If that component didn't render, they're the same objects, and React skips them. - "Keeping all state at the top makes an app faster." No. State at the top renders everything below it on every change. Keep state as low as the components that use it allow.
- "A virtualized list has all its rows in the page, just hidden." No. Only the rows near the screen exist. That's where the speed comes from, and why find-in-page can't see the others.
- "Split every component with
lazy." No. Each split is another request and another loading state. Split big, rarely used parts and whole pages. - "
useState(build())only builds once." No. JavaScript callsbuild()on every render, beforeuseStatesees it. Pass() => build()instead. - "With the React Compiler, none of this matters." No. The compiler skips renders for you, but it doesn't virtualize lists, split code, or decide where your state should live.
Practice exercises
The grid exercises use the same 12 slow teas, in the ProductGrid.jsx tab, and the same render counter. Change the code with Edit & run; the result updates as you type.
The page has just loaded. You type tea in the newsletter box: three keys. What does the grid's counter say? Answer, then press Edit & run and try it.
import { useState } from "react";
import ProductGrid from "./ProductGrid.jsx";
export default function ShopPage() {
const [email, setEmail] = useState("");
return (
<>
<ProductGrid />
<footer>
<label>
Tea news: <input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" />
</label>
<p>{email.includes("@") ? "Looks good." : "Type your email."}</p>
</footer>
</>
);
}import { useState } from "react";import ProductGrid from "./ProductGrid.jsx"; export default function ShopPage() { const [email, setEmail] = useState(""); return ( <> <ProductGrid /> <footer> <label> Tea news: <input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" /> </label> <p>{email.includes("@") ? "Looks good." : "Type your email."}</p> </footer> </> );}The first render makes it 1. Each of the three keys renders ShopPage, which holds email, and the grid inside it, so the counter says renders: 4.
Each click on Help renders the whole grid. Move the state so it doesn't. After three clicks on Help, what does the grid's counter say?
import { useState } from "react";import ProductGrid from "./ProductGrid.jsx"; export default function ShopPage() { const [showHelp, setShowHelp] = useState(false); return ( <> <button onClick={() => setShowHelp(!showHelp)}>Help</button> {showHelp && <p>Click a tea to see its details.</p>} <ProductGrid /> </> );}import { useState } from "react";import ProductGrid from "./ProductGrid.jsx"; function HelpButton() { const [showHelp, setShowHelp] = useState(false); return ( <> <button onClick={() => setShowHelp(!showHelp)}>Help</button> {showHelp && <p>Click a tea to see its details.</p>} </> );} export default function ShopPage() { return ( <> <HelpButton /> <ProductGrid /> </> );}Lines 4 to 12 are HelpButton, which holds showHelp with the button and the text it shows. ShopPage has no state now, so clicking Help renders only HelpButton, and the grid stays at renders: 1.
Each click on Highlight renders the grid again. Keep the frame's state where it is, but stop the grid from rendering. After two clicks, what does the grid's counter say?
import { useState } from "react";import ProductGrid from "./ProductGrid.jsx"; function HighlightFrame() { const [on, setOn] = useState(false); return ( <div style={{ outline: on ? "2px solid orange" : "none", padding: 4 }}> <button onClick={() => setOn(!on)}>Highlight</button> <ProductGrid /> </div> );} export default function ShopPage() { return <HighlightFrame />;}import { useState } from "react";import ProductGrid from "./ProductGrid.jsx"; function HighlightFrame({ children }) { const [on, setOn] = useState(false); return ( <div style={{ outline: on ? "2px solid orange" : "none", padding: 4 }}> <button onClick={() => setOn(!on)}>Highlight</button> {children} </div> );} export default function ShopPage() { return ( <HighlightFrame> <ProductGrid /> </HighlightFrame> );}Line 4 takes children and line 9 places them. ShopPage writes the grid on line 17, so it's the same element after every click, and React skips it: renders: 1.
The order history puts all 5,000 rows in the page. Use the VirtualList.jsx tab, with rows 30 pixels tall in a box 240 pixels tall. Right after it loads, how many rows are in the page?
import { useState } from "react";import { orders } from "./orders.js"; export default function OrderHistory() { const [query, setQuery] = useState(""); const matches = orders.filter((order) => order.name.toLowerCase().includes(query.toLowerCase())); return ( <> <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Filter orders" /> <p>Rows in the page: {matches.length} of {matches.length}</p> <ul className="orders"> {matches.map((order) => ( <li key={order.id}>#{order.id} {order.name}</li> ))} </ul> </> );}import { useState } from "react";import { orders } from "./orders.js";import VirtualList from "./VirtualList.jsx"; export default function OrderHistory() { const [query, setQuery] = useState(""); const matches = orders.filter((order) => order.name.toLowerCase().includes(query.toLowerCase())); return ( <> <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Filter orders" /> <VirtualList items={matches} rowHeight={30} height={240} renderRow={(order) => `#${order.id} ${order.name}`} /> </> );}VirtualList draws the 8 rows that fit in 240 px plus 2 more below them, so the page has 10 rows right after it loads: Rows in the page: 10 of 5000.
The checkout loads the big gift-wrap editor with the page; the console says GiftWrapEditor.jsx runs at once. Load it with lazy instead, and start the download when the pointer enters the button or the button gets focus. You hover, then click: how many times does the console say Downloading GiftWrapEditor.jsx?
import { useState } from "react";import GiftWrapEditor from "./GiftWrapEditor.jsx"; export default function Checkout() { const [wrap, setWrap] = useState(false); return ( <> <h3>Checkout</h3> <button onClick={() => setWrap(true)}>Add gift wrap</button> {wrap && <GiftWrapEditor />} </> );}import { Suspense, lazy, useState } from "react"; let editorDownload = null;function loadEditor() { editorDownload ??= download(import("./GiftWrapEditor.jsx")); return editorDownload;}const GiftWrapEditor = lazy(loadEditor); // Pretends the file takes a second to download.function download(file) { console.log("Downloading GiftWrapEditor.jsx"); return new Promise((resolve) => setTimeout(() => resolve(file), 1000));} export default function Checkout() { const [wrap, setWrap] = useState(false); return ( <> <h3>Checkout</h3> <button onPointerEnter={loadEditor} onFocus={loadEditor} onClick={() => setWrap(true)}> Add gift wrap </button> {wrap && ( <Suspense fallback={<p>Loading the editor…</p>}> <GiftWrapEditor /> </Suspense> )} </> );}Lines 3 to 8 keep one download and give the same function to lazy. Line 21 starts it on hover or focus. Hovering logs Downloading GiftWrapEditor.jsx; the click reuses the same download, so the line appears once.
Every key builds the search index again. Fix it, press Run again, then type green. How many new Building the search index lines does typing add?
import { useState } from "react";import { products } from "./products.js"; function buildSearchIndex(list) { console.log("Building the search index"); const index = new Map(); for (const product of list) { for (const word of product.name.toLowerCase().split(" ")) { if (!index.has(word)) index.set(word, []); index.get(word).push(product); } } return index;} export default function ProductSearch() { const [index] = useState(buildSearchIndex(products)); const [word, setWord] = useState(""); const matches = index.get(word.toLowerCase()) ?? []; return ( <> <input value={word} onChange={(e) => setWord(e.target.value)} placeholder="One word, like green" /> <p>{matches.length} matches</p> </> );}import { useState } from "react";import { products } from "./products.js"; function buildSearchIndex(list) { console.log("Building the search index"); const index = new Map(); for (const product of list) { for (const word of product.name.toLowerCase().split(" ")) { if (!index.has(word)) index.set(word, []); index.get(word).push(product); } } return index;} export default function ProductSearch() { const [index] = useState(() => buildSearchIndex(products)); const [word, setWord] = useState(""); const matches = index.get(word.toLowerCase()) ?? []; return ( <> <input value={word} onChange={(e) => setWord(e.target.value)} placeholder="One word, like green" /> <p>{matches.length} matches</p> </> );}With a function, React calls it only on the first render (twice in development, because of StrictMode). Typing green adds no Building the search index lines.
Check your understanding
For each question, ask what changed, which component holds it, and what that component made new.
Question 1 of 8You type one letter in the newsletter box. Which components render?
Read the code, then predictJSXfunction ShopPage() { const [email, setEmail] = useState(""); return ( <> <ProductGrid /> <NewsletterBox email={email} onChange={setEmail} /> </> ); }Choose an answer to see the explanation.
Question 2 of 8You press Larger. Does ProductGrid render?
Read the code, then predictJSXfunction TextSizer({ children }) { const [size, setSize] = useState(16); return ( <div style={{ fontSize: size }}> <button onClick={() => setSize(size + 2)}>Larger</button> {children} </div> ); } // Somewhere above: <TextSizer> <ProductGrid /> </TextSizer>Choose an answer to see the explanation.
Question 3 of 8What keeps
childrenthe same object after TextSizer's own state changes?Choose an answer to see the explanation.
Question 4 of 8At the top of this list, how many rows does the lesson's VirtualList put in the page?
Read the code, then predictJSX<VirtualList items={orders} // 10,000 orders rowHeight={40} height={400} renderRow={(order) => order.name} />Choose an answer to see the explanation.
Question 5 of 8What do you give up when you virtualize a list?
Choose an answer to see the explanation.
Question 6 of 8With
lazy(() => import("./ReviewsChart.jsx")), when does the browser download the chart's file?Choose an answer to see the explanation.
Question 7 of 8How often does this line call
buildSearchIndex?Read the code, then predictJSXconst [index] = useState(buildSearchIndex(products));Choose an answer to see the explanation.
Question 8 of 8When is
memothe right fix instead of these patterns?Choose an answer to see the explanation.
Key takeaways
- A state change renders the component that holds it and everything it returns. Nothing above or beside it renders.
- Keep state in the component that uses it. Lift it only as far as the components that share it.
- A wrapper with state should take its contents as
children: elements made by a parent that didn't render are the same objects, and React skips them. - For long lists, render only the rows near the screen. Use react-window or TanStack Virtual in real apps, and remember that find-in-page only sees rows in the page.
- Load big, rarely used parts with
lazyandimport(), and start the download on hover or focus. - Keep slow work out of rendering: at the top of the file, in a state initializer, in
useMemo, or in an event handler. - Measure first, and prove each fix with a counter or a second profile.
Remember the one-liner.
The fastest render is the one that never starts: put state where it's used, pass the rest through, and draw only what's on screen.
Coming next: Component composition. It builds on children: slots, compound components, and components that hand control back to the code that uses them.