1. React DevTools Profiler
Before optimizing anything, measure. The React DevTools browser extension includes a Profiler tab: click record, interact with your app, stop recording, and see exactly which components rendered, how long each took, and — critically — why each one rendered.
// For each component that rendered during the recording:
// - Render duration (ms)
// - "Why did this render?" -- props changed, state changed, parent re-rendered, context changed
// - A flame graph: wider bars = more time spent
The "why did this render" information is the most actionable part — it tells you
directly whether a slow component is slow because of expensive work inside it (a
candidate for useMemo, Week 3) or because it re-rendered unnecessarily
due to a parent re-rendering with a fresh prop reference (a candidate for
React.memo + useCallback, also Week 3).
It's tempting to sprinkle useMemo everywhere "to be safe" — Week 3 already covered why that's a net negative for cheap computations. The Profiler turns "I think this might be slow" into "this component genuinely took 40ms and re-rendered 12 times it didn't need to," which is what should actually guide where you spend optimization effort.
2. React.memo, Revisited
Week 3 introduced useCallback in the context of React.memo
without deep-diving memo itself. React.memo wraps a
component so React skips re-rendering it when its props are shallowly equal to last
time:
const ProductCard = React.memo(function ProductCard({ product }: { product: Product }) {
console.log('rendering', product.name);
return <div>{product.name} — ${product.price}</div>;
});
"Shallowly equal" means each individual prop is compared with Object.is
— exactly the same comparison Week 2's dependency arrays use. This is precisely why a
fresh object or function reference on every render (an inline handler, an inline
array literal) defeats memo even when the data inside it is identical —
the same lesson Week 3 covered for useCallback, now generalized: memoization
only works when the things being compared are actually stable across renders.
A quick heuristic for when React.memo is worth adding: the component
renders often relative to how often its actual props change, and rendering it is
measurably not free — confirmed by the Profiler, not guessed at.
3. Route-Based Code-Splitting
By default, a bundler ships your entire app as one (or a few) JavaScript files — a user visiting the login page downloads the code for the admin dashboard too, even though they'll likely never see it. Code-splitting breaks the bundle into pieces loaded on demand.
import DashboardPage from './pages/DashboardPage';
import SettingsPage from './pages/SettingsPage';
import AdminPage from './pages/AdminPage'; // large, rarely visited -- shipped to everyone anyway
const router = createBrowserRouter([
{ path: '/dashboard', element: <DashboardPage /> },
{ path: '/settings', element: <SettingsPage /> },
{ path: '/admin', element: <AdminPage /> },
]);
import { lazy, Suspense } from 'react';
import DashboardPage from './pages/DashboardPage';
import SettingsPage from './pages/SettingsPage';
const AdminPage = lazy(() => import('./pages/AdminPage'));
const router = createBrowserRouter([
{ path: '/dashboard', element: <DashboardPage /> },
{ path: '/settings', element: <SettingsPage /> },
{
path: '/admin',
element: (
<Suspense fallback={<PageSkeleton />}>
<AdminPage />
</Suspense>
),
},
]);
React.lazy(() => import('./pages/AdminPage')) tells the bundler to put
AdminPage and everything it imports into a separate file, fetched only
the first time a user actually navigates to /admin. This is the exact
same Suspense component from Week 12 — lazy is a component
that suspends while its chunk downloads, using the identical mechanism as a
suspending data fetch.
4. Lazy-Loading Non-Route Code
The same pattern applies to anything heavy and conditionally shown, not just whole routes — a modal, a rich text editor, a charting library only used on one rarely-visited screen:
import { lazy, Suspense, useState } from 'react';
const AvatarCropper = lazy(() => import('./AvatarCropper')); // pulls in a large image library
function SettingsPage() {
const [isCropping, setIsCropping] = useState(false);
return (
<div>
<button onClick={() => setIsCropping(true)}>Change avatar</button>
{isCropping && (
<Suspense fallback={<p>Loading editor...</p>}>
<AvatarCropper />
</Suspense>
)}
</div>
);
}
Most users who visit SettingsPage never click "Change avatar" at all —
lazy-loading AvatarCropper means its (potentially large) image-processing
dependencies never download for those users, only for the ones who actually trigger it.
5. Bundle Analysis
To know what's actually worth splitting, you need to see what's in your bundle in the
first place. rollup-plugin-visualizer (Vite is built on Rollup) generates
an interactive treemap of your final bundle:
npm install -D rollup-plugin-visualizer
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
react(),
visualizer({ open: true, gzipSize: true }),
],
});
Running a production build (npm run build) now opens a treemap where box
size represents each dependency's actual size in the final bundle. This routinely
surfaces surprises: a date library pulled in wholesale for one formatting call, a
charting library imported at the top level when only one rarely-visited page uses it
— both are prime code-splitting candidates once you can actually see them.
Sections 1–2 (Profiler, memo) are about how fast components render once their code is already downloaded. Sections 3–5 are about how fast the code arrives in the first place. Both matter, but they're solved with completely different tools — don't reach for useMemo to fix a slow initial page load; that's a bundle-size problem.
6. Hands-on Exercise
Profile, split, and measure the difference
Find a real bottleneck, fix it two different ways, and prove the fix worked with actual measurements.
Requirements:
- Take an app from an earlier week (Week 3's 20,000-row product list is a good candidate) and record a profile of an interaction that feels sluggish using the React DevTools Profiler.
- Identify, from the profile's "why did this render" data, one component re-rendering unnecessarily, and fix it with
React.memo+useCallback. Re-profile and confirm the fix in the flame graph. - Add a second route to the app behind a heavy component (import a large library like a charting library, or just a component with a deliberately large inline array as filler) and code-split it with
React.lazy. - Run a production build with the bundle visualizer enabled, and confirm visually that the lazy-loaded route appears as a separate chunk, not part of the main bundle.
- Open your browser's Network tab, load the app, and confirm the heavy route's code only downloads when you actually navigate to it.
The Profiler only records while you're actively recording — click "Record," perform the exact interaction you're investigating, then stop. Recording your app's initial load or unrelated interactions will bury the signal you're actually looking for in noise.
7. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
What specific piece of information does the React DevTools Profiler give you that a general "this feels slow" hunch doesn't?
What specific piece of information does the React DevTools Profiler give you that a general "this feels slow" hunch doesn't?
Exactly which components rendered, how long each took, and why each one rendered (props changed, state changed, parent re-rendered, context changed). That last part tells you whether a slow component needs useMemo for genuinely expensive work, or React.memo/useCallback because it's re-rendering unnecessarily — two different problems with two different fixes.
Q2
What comparison does React.memo use to decide whether to skip a re-render?
What comparison does React.memo use to decide whether to skip a re-render?
A shallow, per-prop Object.is comparison against the previous render's props — the same comparison used by dependency arrays and by useMemo/useCallback internally. That's why a fresh object or function reference passed as a prop on every render defeats the memoization, even if its contents are identical.
Q3
What relationship does React.lazy have to Week 12's Suspense?
What relationship does React.lazy have to Week 12's Suspense?
They use the same underlying mechanism — a lazily-loaded component suspends (in the exact technical sense from Week 12) while its JavaScript chunk downloads, and the nearest wrapping Suspense boundary shows its fallback until that download completes. It's the same "not ready yet" signal Week 12 used for data fetching, applied here to loading code instead of loading data.
Q4
Would using useMemo more aggressively fix a slow initial page load caused by a large JavaScript bundle?
Would using useMemo more aggressively fix a slow initial page load caused by a large JavaScript bundle?
No. useMemo addresses render-time cost — how long a component takes to compute its output once its code is already downloaded and running. A slow initial load is a bundle-size/loading-time problem, solved by code-splitting (Sections 3–4) and understanding what's actually in the bundle (Section 5) — a completely different bottleneck requiring different tools.