Same mall. New problems: the mall got big, shoppers get impatient, and Management wants proof things actually work before opening day.
Setting the Scene
👦 Nephew: Uncle, the mall works. But now it's huge — hundreds of shops, thousands of shoppers. Some pages feel sluggish, and I have no idea why something re-renders. Where do I even start looking?
👨🦳 Uncle: With the security camera footage. Every mall this size has one tool you check before guessing anything.
Today's Map
Part 1: The Mall (components, hooks, routing) ✅
↓
PART 2 ← WE ARE HERE
│
├─ Performance & Optimization
│ ├─ The Security Camera (DevTools Profiler)
│ ├─ Only Build What's In View (Virtualization)
│ ├─ Don't Call the Warehouse on Every Keystroke (Debounce/Throttle)
│ ├─ Why the Doors Take Forever to Open (Bundle Size)
│ └─ Measuring Before vs After the Paint (useLayoutEffect)
│
├─ Concurrent Features
│ ├─ Marking Work as "Not Urgent" (useTransition)
│ ├─ The Slightly-Delayed Display (useDeferredValue)
│ └─ Security Batching Requests Together (Automatic Batching)
│
└─ Testing
├─ Rehearsing a Shop Alone (Unit Testing)
├─ The Stand-In Warehouse (Mocking)
└─ Do Neighboring Shops Actually Work Together (Integration Testing)
↓
A Mall That's Fast, Responsive, and Provably Correct
PERFORMANCE & OPTIMIZATION
Part 1: The Security Camera — React DevTools Profiler
👦 Nephew: How do I even know which shop is causing the slowdown?
👨🦳 Uncle: You don't guess — you review the footage. The React DevTools Profiler is a security camera that records every single re-render across the mall: which shop redecorated, how long it took, and — most importantly — why it redecorated at all.
Recording a shopper's click...
Render #1 (14ms)
├─ Header (0.2ms) — didn't need to re-render, SKIPPED
├─ ProductGrid (11.8ms) ← the slow one!
│ ├─ ProductCard × 200 (0.05ms each, but 200 of them add up)
└─ Footer (0.1ms) — didn't need to re-render, SKIPPED
👦 Nephew: So ProductGrid re-rendered 200 cards, but the click only actually changed one thing?
👨🦳 Uncle: Exactly the pattern the camera is built to catch. Open the Profiler tab, click "record," interact with the mall, stop recording — it shows you a flame graph: wider bars took longer, and it tells you why each component re-rendered (props changed, state changed, or a parent simply re-rendered and dragged its children along for no real reason).
// You can also profile programmatically with React's <Profiler> component
import { Profiler } from 'react';
function onRenderCallback(id, phase, actualDuration) {
console.log(`${id} (${phase}) took ${actualDuration}ms`);
}
<Profiler id="ProductGrid" onRender={onRenderCallback}>
<ProductGrid />
</Profiler>
👦 Nephew: So step one, always, is: look at the footage before guessing?
👨🦳 Uncle: Always. Optimizing blind is how people sprinkle React.memo everywhere and make things worse — memoization itself isn't free, it costs a comparison check every render. Measure first, fix the actual bottleneck, remeasure.
Part 2: Only Build What's In View — Virtualization
👦 Nephew: The camera shows ProductGrid is slow because it's rendering all 10,000 products in the catalog, even though the shopper can only see about 20 at a time on screen.
👨🦳 Uncle: Then stop building storefronts nobody's looking at. Imagine a directory listing 10,000 shops — you don't physically construct all 10,000 storefronts on day one. You build only the ones currently visible through the escalator window, and swap them out as the shopper scrolls.
WITHOUT virtualization WITH virtualization
──────────────────────── ─────────────────────
Render all 10,000 product cards Render only the ~20 visible
into the DOM at once ones, recycling DOM nodes as
the shopper scrolls
↓ ↓
10,000 DOM nodes, slow scroll, ~20 DOM nodes, buttery smooth
high memory usage scroll, low memory usage
import { FixedSizeList } from 'react-window';
function ProductGrid({ products }) {
return (
<FixedSizeList
height={600}
itemCount={products.length}
itemSize={80}
width="100%"
>
{({ index, style }) => (
<div style={style}>
{products[index].name}
</div>
)}
</FixedSizeList>
);
}
👦 Nephew: So react-window is the escalator glass — only what's currently behind it gets built?
👨🦳 Uncle: Exactly that picture. As the shopper scrolls, react-window recycles the same handful of DOM nodes, just swapping which product's data they display — instead of ever having 10,000 real nodes sitting in the document at once.
Part 3: Don't Call the Warehouse on Every Keystroke — Debouncing & Throttling
👦 Nephew: The search bar calls our stock-check API on every single keystroke. Typing "laptop" fires the warehouse call 6 times.
👨🦳 Uncle: Two different fixes for two different situations, and people mix them up constantly.
Debounce — wait until the shopper pauses typing before calling the warehouse at all:
function useDebounce(value, delayMs) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer); // cancel if value changes again before delay ends
}, [value, delayMs]);
return debounced;
}
function SearchBar() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 400); // waits 400ms of silence
useEffect(() => {
if (debouncedQuery) searchWarehouse(debouncedQuery);
}, [debouncedQuery]);
return <input onChange={(e) => setQuery(e.target.value)} />;
}
Throttle — allow the call to happen, but no more than once every fixed interval, no matter how fast the shopper acts:
function useThrottle(callback, delayMs) {
const lastRun = useRef(0);
return (...args) => {
const now = Date.now();
if (now - lastRun.current >= delayMs) {
lastRun.current = now;
callback(...args);
}
};
}
// Good for scroll-position tracking, resize handlers — continuous events
// where you want REGULAR updates, not just a final one after silence
const handleScroll = useThrottle(() => logScrollPosition(), 200);
👦 Nephew: So debounce is "wait for them to stop," and throttle is "let it happen, but only this often"?
👨🦳 Uncle: Exactly the distinction. Debounce fits search bars — you only care about the final typed value. Throttle fits scroll or resize tracking — you want steady updates throughout, not just a single one at the end.
Part 4: Why the Doors Take Forever to Open — Bundle Size
👦 Nephew: The mall itself takes 8 seconds to even show anything on a slow connection. That's before any of the performance stuff we just discussed even matters.
👨🦳 Uncle: Because the shopper is downloading the entire construction crew's toolkit — every shop's code, every library — before the doors can open at all. This is your bundle size problem, and it's a completely different layer from render performance.
// Check what's actually inside your bundle
// npx source-map-explorer build/static/js/*.js
// (or use the Next.js bundle analyzer, webpack-bundle-analyzer, etc.)
// Common offenders:
// - A massive date library imported for one formatting function
// - An entire icon set imported when you use 5 icons
// - Duplicate copies of the same dependency at different versions
👦 Nephew: We already covered React.lazy in Part 1 for individual shops. Is bundle size the same fix?
👨🦳 Uncle: Same tool, bigger scale. Instead of lazy-loading one slow shop, you split the mall by route — the Electronics section's entire code doesn't download until a shopper actually walks toward Electronics.
// Route-level code splitting — each section only loads when visited
const Electronics = React.lazy(() => import('./sections/Electronics'));
const FoodCourt = React.lazy(() => import('./sections/FoodCourt'));
<Suspense fallback={<MallLoadingScreen />}>
<Routes>
<Route path="/electronics" element={<Electronics />} />
<Route path="/food-court" element={<FoodCourt />} />
</Routes>
</Suspense>
WITHOUT route splitting WITH route splitting
──────────────────────── ──────────────────────
One giant bundle, all sections, Small initial bundle (just entrance)
downloaded before doors open Each section's code downloads only
↓ when a shopper actually visits it
Slow initial load, even if ↓
shopper only visits 1 section Fast initial load, remaining
sections load on demand
👨🦳 Uncle: Also audit your suppliers — a date-formatting library that's 300KB just to format "Jan 5, 2026" is a bad trade when a 2KB alternative does the same job. Bundle size problems are usually solved by removing or replacing dependencies, not by clever React code.
Part 5: Measuring Before vs After the Paint — useLayoutEffect
👦 Nephew: I need to measure a shop window's exact size right after it renders, and position a tooltip based on that measurement — but I get a visible flicker, like the tooltip jumps position for a split second.
👨🦳 Uncle: That flicker is the giveaway. useEffect runs after the browser has already painted the screen — so if you measure and reposition something inside it, the shopper briefly sees the wrong position before the correction happens.
useEffect timing:
Render → Browser PAINTS to screen → useEffect runs → (flicker if you reposition here)
useLayoutEffect timing:
Render → useLayoutEffect runs (measure + adjust) → Browser PAINTS to screen (no flicker)
function Tooltip({ targetRef }) {
const tooltipRef = useRef();
const [position, setPosition] = useState({ top: 0, left: 0 });
useLayoutEffect(() => {
// Runs BEFORE the browser paints — measurement and correction
// happen invisibly, the shopper never sees the wrong position
const rect = targetRef.current.getBoundingClientRect();
setPosition({ top: rect.bottom, left: rect.left });
}, [targetRef]);
return <div ref={tooltipRef} style={position}>Tooltip content</div>;
}
👦 Nephew: So the rule is: use useLayoutEffect only when the shopper would actually notice a visual glitch otherwise?
👨🦳 Uncle: Exactly the rule. useLayoutEffect blocks the browser from painting until it finishes, so overusing it for things that don't need it — API calls, logging, anything non-visual — just makes the mall feel slower to open each screen. Reach for it specifically for DOM measurements and visual corrections; use plain useEffect for everything else.
CONCURRENT FEATURES
Part 6: Marking Work as "Not Urgent" — useTransition
👦 Nephew: We have a search bar that filters a list of 5,000 products as you type. Typing feels laggy because the filtering itself is heavy.
👨🦳 Uncle: Here's where Security (Fiber, from Part 1) gets a new tool. You tell it directly: "the typing itself is urgent — keep that instant. But the heavy filtering work that follows? That's not urgent — deprioritize it if something more important comes up."
import { useState, useTransition } from 'react';
function ProductSearch({ allProducts }) {
const [query, setQuery] = useState('');
const [filtered, setFiltered] = useState(allProducts);
const [isPending, startTransition] = useTransition();
function handleChange(e) {
const value = e.target.value;
setQuery(value); // URGENT — the input box updates instantly, no lag
startTransition(() => {
// NOT URGENT — Security can pause/deprioritize this heavy work
// if the shopper keeps typing, without freezing the input box
setFiltered(allProducts.filter(p => p.name.includes(value)));
});
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <p>Updating results...</p>}
<ProductGrid products={filtered} />
</>
);
}
👦 Nephew: So the input box never freezes, even if the filtering takes a moment?
👨🦳 Uncle: Exactly. isPending gives you a flag to show a subtle "updating..." indicator, while the actual keystroke response stays instant, always. This is Security actively choosing what deserves the spotlight right now, versus what can wait a beat.
Part 7: The Slightly-Delayed Display — useDeferredValue
👦 Nephew: That looks similar to what I'd want for a heavy chart that re-renders based on a slider's value. Is it the same tool?
👨🦳 Uncle: Related, but different shape. useTransition wraps an action you trigger. useDeferredValue wraps a value itself, letting a laggy display "catch up when it can," without you needing to restructure your update logic around startTransition.
function PriceHistoryChart({ rawSliderValue }) {
// The chart uses a slightly-delayed version of the value —
// it's allowed to lag a moment behind the actual slider position
const deferredValue = useDeferredValue(rawSliderValue);
return <HeavyChart value={deferredValue} />;
}
function SliderDemo() {
const [value, setValue] = useState(50);
return (
<>
{/* The slider itself stays perfectly responsive */}
<input type="range" value={value} onChange={(e) => setValue(+e.target.value)} />
<PriceHistoryChart rawSliderValue={value} />
</>
);
}
👦 Nephew: So the slider handle moves instantly, but the heavy chart underneath is allowed to be a half-step behind?
👨🦳 Uncle: Exactly — like a mall's digital price display that updates a beat after the actual register total changes, so the register itself never stutters waiting for the display to catch up. The shopper barely notices the tiny lag on the chart, but absolutely would notice a laggy slider.
Part 8: Security Batching Requests Together — Automatic Batching
👦 Nephew: If I call setState three times in a row inside an async function, does the mall redecorate three separate times?
👨🦳 Uncle: In React 17 and earlier — yes, if those calls happened outside a normal click handler (say, inside a fetch().then() or a setTimeout). Each call triggered its own separate re-render.
// React 17: inside a setTimeout or promise callback
setTimeout(() => {
setCount(c => c + 1); // Re-render #1
setFlag(f => !f); // Re-render #2 — separate!
}, 1000);
React 18 fixed this — Security now batches these together automatically, no matter where they happen:
// React 18: same code, now automatically batched
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// Only ONE re-render happens, combining both updates
}, 1000);
👦 Nephew: So Security used to only batch requests that arrived during the same "click," but now batches everything together regardless of when it arrives?
👨🦳 Uncle: Exactly the upgrade — fewer unnecessary re-renders, automatically, without you having to manually group your setState calls together like people used to do with workarounds. If you genuinely need a specific update to happen immediately, outside of batching, flushSync exists as an escape hatch — but reach for it rarely, it's specifically for edge cases like measuring the DOM immediately after a state change.
TESTING
Part 9: Rehearsing a Shop Alone — Unit Testing
👦 Nephew: How do I actually prove a single shop behaves correctly, without opening the whole mall?
👨🦳 Uncle: You rehearse it alone, in an empty room, using React Testing Library with Jest. The philosophy matters here: test what a shopper would experience, not the shop's internal wiring.
// PriceTag.jsx
function PriceTag({ price, onSale }) {
return (
<div>
<span>${price}</span>
{onSale && <span data-testid="sale-badge">SALE</span>}
</div>
);
}
// PriceTag.test.jsx
import { render, screen } from '@testing-library/react';
import PriceTag from './PriceTag';
test('shows the price', () => {
render(<PriceTag price={499} onSale={false} />);
expect(screen.getByText('$499')).toBeInTheDocument();
});
test('shows a sale badge when on sale', () => {
render(<PriceTag price={499} onSale={true} />);
expect(screen.getByTestId('sale-badge')).toBeInTheDocument();
});
test('does NOT show a sale badge when not on sale', () => {
render(<PriceTag price={499} onSale={false} />);
expect(screen.queryByTestId('sale-badge')).not.toBeInTheDocument();
});
👦 Nephew: Why screen.getByText instead of just checking the component's internal state directly?
👨🦳 Uncle: Because a shopper never sees your internal state — they see what's actually displayed. Testing library deliberately makes it awkward to reach into internals, forcing you to test behavior ("does the sale badge show?") instead of implementation ("is the onSale prop being read correctly?"). Implementation can change completely; behavior is what actually matters to the shopper.
Testing an interaction — a shopper clicking "add to cart":
function AddToCartButton({ onAdd }) {
const [added, setAdded] = useState(false);
return (
<button onClick={() => { onAdd(); setAdded(true); }}>
{added ? 'Added!' : 'Add to Cart'}
</button>
);
}
import { render, screen, fireEvent } from '@testing-library/react';
test('clicking the button calls onAdd and updates the label', () => {
const mockOnAdd = jest.fn();
render(<AddToCartButton onAdd={mockOnAdd} />);
const button = screen.getByText('Add to Cart');
fireEvent.click(button);
expect(mockOnAdd).toHaveBeenCalledTimes(1);
expect(screen.getByText('Added!')).toBeInTheDocument();
});
👦 Nephew: jest.fn() — that's creating a fake function just to check it got called?
👨🦳 Uncle: Exactly — a mock function, a clipboard that silently records "I was called, this many times, with these arguments," without needing a real implementation behind it.
Part 10: The Stand-In Warehouse — Mocking API Calls
👦 Nephew: What about a shop that fetches live stock data from the actual warehouse (a real API)? I don't want tests hitting a real server every time.
👨🦳 Uncle: You bring in a stand-in warehouse — a mock — that returns fake, predictable data instantly, without any real network call.
// StockChecker.jsx
function StockChecker({ productId }) {
const [stock, setStock] = useState(null);
useEffect(() => {
fetch(`/api/stock/${productId}`)
.then(res => res.json())
.then(data => setStock(data.quantity));
}, [productId]);
if (stock === null) return <p>Checking stock...</p>;
return <p>{stock} units available</p>;
}
import { render, screen, waitFor } from '@testing-library/react';
import StockChecker from './StockChecker';
test('displays stock count after fetching', async () => {
// Stand in for the real warehouse — fetch never actually leaves this test
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ quantity: 42 }),
})
);
render(<StockChecker productId="abc123" />);
expect(screen.getByText('Checking stock...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('42 units available')).toBeInTheDocument();
});
expect(fetch).toHaveBeenCalledWith('/api/stock/abc123');
});
👦 Nephew: Why waitFor?
👨🦳 Uncle: Because the real fetch is asynchronous — even a fake one resolves on a future tick, not instantly. waitFor politely waits, retrying its check, until the awaited condition becomes true, instead of checking once and failing prematurely.
Part 11: Do Neighboring Shops Actually Work Together? — Integration Testing
👦 Nephew: Each shop passes its own unit tests individually. But what if the connection between two shops is broken — like adding an item in one shop doesn't actually update the cart icon in the header?
👨🦳 Uncle: That's exactly what unit tests, by design, can't catch — each one deliberately tests a shop in isolation. Integration testing renders several real shops together and checks that the connections between them genuinely work.
// Rendering the actual Header + ProductPage together, not in isolation
import { render, screen, fireEvent } from '@testing-library/react';
import { CartProvider } from './CartContext';
import Header from './Header';
import ProductPage from './ProductPage';
test('adding a product updates the cart badge in the header', () => {
render(
<CartProvider>
<Header />
<ProductPage product={{ id: 1, name: 'Shoes', price: 999 }} />
</CartProvider>
);
expect(screen.getByTestId('cart-count')).toHaveTextContent('0');
fireEvent.click(screen.getByText('Add to Cart'));
expect(screen.getByTestId('cart-count')).toHaveTextContent('1');
});
👦 Nephew: So this test would fail if Header and ProductPage weren't actually reading from the same shared cart state?
👨🦳 Uncle: Exactly the bug this catches — two shops that individually pass all their unit tests, but were never actually wired together correctly through Context or Redux. A healthy test suite has many small, fast unit tests, and a smaller number of integration tests checking the important connections between shops — not one giant test trying to open the entire mall at once.
The Complete Part 2 Toolkit
PERFORMANCE
─────────────
Slow mall? → Check the security camera (Profiler) FIRST, never guess
Huge list? → Virtualize (react-window) — only build what's visible
Excess API calls? → Debounce (wait for silence) or Throttle (steady pace)
Slow initial load? → Split by route, audit dependencies, check bundle size
Visual flicker? → useLayoutEffect for DOM measurements, useEffect for everything else
CONCURRENCY
─────────────
Laggy typing during heavy work? → useTransition — mark the heavy part "not urgent"
Laggy heavy display following a fast control? → useDeferredValue
Multiple setState calls outside a click handler? → Automatically batched since React 18
TESTING
─────────────
One shop's behavior? → Unit test with React Testing Library, test behavior not internals
A shop that calls a real API? → Mock the fetch, use waitFor for async assertions
Do two shops work together? → Integration test, render them together for real
Key Takeaways
- Measure before optimizing — the DevTools Profiler tells you what's actually slow and why; guessing leads to wasted effort and sometimes worse performance
- Virtualization renders only what's visible — essential for any list past a few hundred items
- Debounce waits for a pause (search bars); throttle enforces a steady pace (scroll/resize)
- Bundle size is a separate problem from render performance — solved by code splitting and auditing dependencies, not clever component code
-
useLayoutEffectruns before the browser paints — use it only for DOM measurements that would otherwise cause visible flicker -
useTransitionmarks an action as low priority, keeping urgent updates (like typing) instant -
useDeferredValuemarks a value as allowed to lag behind, useful for expensive displays following a fast control - Automatic batching in React 18 groups state updates together everywhere, not just inside click handlers
- Unit tests should test behavior a shopper experiences, not internal implementation details
- Mocking replaces real network calls with predictable fake data, keeping tests fast and reliable
- Integration tests catch the bugs unit tests are designed to miss — broken connections between components
👦 Nephew: So Part 1 was building the mall. Part 2 is making sure it's fast, doesn't freeze up under pressure, and provably works before real shoppers ever walk in.
👨🦳 Uncle: Exactly the arc. Anyone can build a mall that works on a good day, on your own laptop, with nobody else watching. The real skill is a mall that stays fast at scale, stays responsive under real user behavior, and keeps working correctly as ten different people keep changing different shops every week. That's the difference between a demo and something you'd actually ship.
Next: React Server Components — the prep kitchen that never opens to customers at all, and why it changes the entire mental model of where your code actually runs.
Remember: less noise, more action. Fast and wrong is still wrong — but slow and untested is just a surprise waiting to happen.
Top comments (0)