Chapters

Hide chapters

React Apprentice

First Edition · web · React 8.0.0 · Visual Studio Code

Section I: Rendering Right

Section 1: 7 chapters
Show chapters Hide chapters

17. Performance, Production, and Deployment
Written by Eli Ganim

The Learning Tracker is built and tested. One thing remains: getting it in front of people — fast, and on a real URL.

The previous chapter gave you a safety net of tests. This chapter is about the last mile. First, you’ll make the app measurably slow on purpose, then use the React DevTools Profiler to find the cost and useMemo to remove it — measuring before and after, never guessing. Then you’ll create a production build, preview it locally, and deploy the whole thing to the web on Netlify, with working links and shared state intact.

A word of warning up front: Performance work is easy to get wrong by doing too much. The rule that runs through this chapter is measure first. Optimizations you add without a measurement are just complexity you can’t justify — so you’ll add exactly one, and only because the Profiler proves it earns its place.

What Makes React Re-render

A component re-renders for three reasons: Its state changes, its parent re-renders (passing possibly-new props), or a context it reads changes. That’s the whole list. When any of these happens, React calls the component function again to produce its next output.

Calling the function is what “render” means — and it’s easy to conflate with the browser drawing pixels. They’re separate stages:

A state change flows through React's render, reconcile and commit steps; then the browser paints — separate stages.
A state change flows through React's render, reconcile and commit steps; then the browser paints — separate stages.

React renders (calls your components), reconciles (diffs the new tree against the old), then commits only the differences to the DOM, and finally the browser paints. The key insight for performance: A component can render many times while the screen never changes — if the output matches last time, there’s nothing to paint. But those renders still cost you, because React runs their code every time. Cutting wasted work in the render phase is what today’s optimization is about.

Adding an Expensive Calculation

To profile a slow render, you need a slow render. Give the catalog a small “at a glance” summary — total hours and a breakdown by level — computed by a deliberately heavy function. Create src/catalogSummary.ts:

import type { Course, CourseLevel } from './types/course.ts'

export type CatalogSummary = {
  totalHours: number
  byLevel: Record<CourseLevel, number>
}

export function summarizeCatalog(
  courses: Course[],
): CatalogSummary {
  let totalHours = 0
  const byLevel: Record<CourseLevel, number> = {
    Beginner: 0,
    Intermediate: 0,
    Advanced: 0,
  }

  // Stand-in for a genuinely expensive analysis — imagine
  // scoring thousands of courses. The repeated passes keep the
  // cost visible in the Profiler; real work would be your own
  // heavy computation.
  for (let pass = 0; pass < 150_000; pass++) {
    totalHours = 0
    byLevel.Beginner = 0
    byLevel.Intermediate = 0
    byLevel.Advanced = 0
    for (const course of courses) {
      totalHours += course.durationHours ?? 0
      byLevel[course.level] += 1
    }
  }

  return { totalHours, byLevel }
}

The real result — hours and level counts — is cheap. The pass loop repeats it 150,000 times purely to stand in for genuinely expensive work, so the cost is big enough to see in the Profiler. Your own bottleneck might be sorting, scoring or formatting a large dataset; the shape of the fix is the same.

Now show it. In src/pages/CatalogPage.tsx, import the type:

import type { CatalogSummary } from '../catalogSummary.ts'

It’s an import type because CatalogSummary is only used in type positions, so the compiler drops it from the bundle entirely. Add summary to the props type, right after onRetry:

  onRetry: () => void
  summary: CatalogSummary

That makes the summary a required input to the page. Add it to the destructured parameters too, again right after onRetry, so the component body can read it:

  onRetry,
  summary,

Now, render it inside the success branch, just below the featured banner:

          <p className="catalog-summary">
            {summary.totalHours} hours of learning ·{' '}
            {summary.byLevel.Beginner} beginner ·{' '}
            {summary.byLevel.Intermediate} intermediate ·{' '}
            {summary.byLevel.Advanced} advanced
          </p>

This reads the numbers straight off the summary object; each {' '} keeps a normal space between segments across the line breaks. TypeScript now flags App.tsx, where CatalogPage is rendered without the new prop — you’ll fix that next by computing the summary. In src/App.tsx, import the function:

import { summarizeCatalog } from './catalogSummary.ts'

Then compute the summary right after courses, the simplest way for now — inline, on every render:

  const summary = summarizeCatalog(courses)

Running it on every render is deliberately naive; that’s the cost you’ll measure and then remove. Pass it into CatalogPage in the route table, alongside the other props:

              summary={summary}

That clears the missing-prop error. Finally, style the line — add this near .result-count at the bottom of src/App.css:

.catalog-summary {
  margin: 0 0 12px;
  font-size: 0.9rem;
  color: var(--text-muted);
}

A muted line to sit quietly above the search box. Save and run the app — the summary appears at the top of the catalog:

The catalog's new at-a-glance summary line, above the search box.
The catalog's new at-a-glance summary line, above the search box.

Now type into the search field, and you’ll feel it: Each keystroke stutters. Every letter re-renders App, and summarizeCatalog runs again from scratch. You’ve built the problem; now measure it.

Measuring With the Profiler

Guessing at performance is how you waste an afternoon optimizing the wrong thing. Measure instead. Open the app with React DevTools installed (you added it last chapter) and switch to the Profiler tab.

Open its settings (the gear icon) and turn on Record why each component rendered while profiling — that clue is worth having. Then click the record button, type a single letter in the search box, and stop recording.

The Profiler shows each commit — one completed render — as a bar. Select the commit from your keystroke and read the flamegraph:

The Profiler flamegraph: App's render takes about 22ms on a single keystroke, dwarfing its children.
The Profiler flamegraph: App's render takes about 22ms on a single keystroke, dwarfing its children.

The numbers will differ on your machine, but the shape won’t. App‘s bar is wide and colored for slowness — around 22 milliseconds — while its children are thin slivers. In the flamegraph a bar’s width is a component’s render time including its children, but here the children are cheap, so nearly all of App’s 22ms is its own work — and only one thing in App is heavy enough to explain it: summarizeCatalog, running on every keystroke.

The panel on the right even tells you why the component rendered — the query state changed — which is expected. What’s not acceptable is redoing the expensive summary when only the search text moved. Anything past ~16ms per frame drops below silky-smooth, and 22ms is well past it.

Optimizing What You Measured

You have a real, measured cost. Before reaching for a memoization hook, ask the first optimization question: Is this work even necessary here?

A tempting instinct is to compute the summary once in an effect and stash it in state — “cache it so it stops recomputing.” Resist that. Derived data in state is a trap: It adds an extra render every time it updates, it can drift out of sync with the real source, and wiring it to the wrong dependencies invites bugs. The summary isn’t independent information; it’s a pure function of the courses. Keep derived data derived.

What you actually want is to keep deriving it, but skip the recompute when its inputs haven’t changed. That’s exactly what useMemo does: It remembers a computed value and only recomputes when a listed dependency changes. Import it in src/App.tsx:

import {
  useEffect,
  useMemo,
  useReducer,
  useRef,
  useState,
} from 'react'

Then replace the inline summary line with a memoized version:

  const summary = useMemo(() => {
    const catalog =
      request.status === 'success' ? request.courses : []
    return summarizeCatalog([
      ...catalog,
      ...personalCourses,
    ])
  }, [request, personalCourses])

The dependency array is the whole point, so choose it carefully. The summary depends only on the actual courses, which come from two pieces of state: request (the fetched catalog) and personalCourses. Neither changes while you type, so during search the memo returns its cached value untouched.

Notice what’s not in the array: courses. That’s a fresh array rebuilt on every render, so depending on it would defeat the memo entirely — the dependency would always look new. Depend on the state a value truly comes from, not on the derived array in between.

Save, then profile the same keystroke again:

The same keystroke after useMemo: App renders in about 1ms, the expensive summary skipped.
The same keystroke after useMemo: App renders in about 1ms, the expensive summary skipped.

App still re-renders on each letter — that’s unavoidable, since query lives there — but its render time collapses from ~22ms to about 1ms, because useMemo skips summarizeCatalog when request and personalCourses are unchanged. Same interaction, one measured change, an order of magnitude faster. That is the entire loop: Measure, change one thing, measure again.

Memoizing Components and Callbacks

useMemo caches a value. React has two neighbors for caching other things, worth knowing even though this project doesn’t need them.

memo wraps a component so it skips re-rendering when its props haven’t changed — it still re-renders on its own state or context changes. useCallback caches a function so its identity stays stable between renders, as long as the function’s dependencies don’t change. The two work together, and understanding why requires one fact about JavaScript:

Note: Every time a component renders, any function it defines inline is a brand-new function — equal in behavior to the previous render’s, but a different value. So onClick={() => onToggle(id)} hands a memoized child a new prop every render, and memo dutifully re-renders it, seeing “changed” props. useCallback keeps that function’s identity stable across renders (until its dependencies change), so the memoized child can actually skip its work. The same is true of arrays and objects — new identity each render — which is why the memo above depends on request, not on the freshly-built courses.

Here’s the shape, for reference — you won’t add it to the project:

const CourseCard = memo(function CourseCard(props) {
  // …only re-renders when its props actually change
})

Why skip it? Because the measurement never asked for it. Our catalog is small, cards re-render in microseconds, and wrapping everything in memo and useCallback would add ceremony and cognitive load for no measured gain.

Memoization is a tool you reach for after the Profiler points at a specific cost — never a habit you sprinkle everywhere. One measured useMemo is exactly the right amount for this app.

A Final Pass Before Shipping

Performance done, give the finished app a last look. Run through this quickly:

  • Error and empty states. The catalog and details pages handle loading, error (with retry) and empty states — you built and tested each in earlier chapters, and the suite still covers them. Give the easily-reachable ones a look: Search for nonsense to see the no-results message, and open a made-up course id like /courses/nope for the not-found page.
  • Accessibility. Tab through the app: Every control is reachable, and focus is visible. Toggle Dark mode and confirm text stays readable in both themes. Landmarks — one main, the header banner, the nav — are intact.
  • The tests. Your safety net only helps if it’s green. Run it:
npm test

All nine pass. Type checking, tests and — next — a production build each catch a different class of problem, so clearing all three is what “ready” means. With that, the app is ready to leave your laptop.

Building for Production

Everything so far has run through Vite’s dev server, which prioritizes fast feedback over a lean result. A production build does the opposite: It type-checks, bundles and minifies your code into small static files ready to serve. Run it:

npm run build

The production build: TypeScript checks pass, then Vite writes hashed files to the dist folder.
The production build: TypeScript checks pass, then Vite writes hashed files to the dist folder.

Recall the script from Chapter 1: tsc -b && vite build. TypeScript checks the whole project first — a failing type ends the build — then Vite writes the output to a new dist folder: an index.html, plus hashed .js and .css files in dist/assets. Those hashes (like index-De2toTEf.js) change when the content changes, which lets browsers cache aggressively without ever serving a stale file. This dist folder — a handful of static files, no Node server required — is your entire app, ready to upload anywhere.

Configuring the SPA Fallback

One detail decides whether routing survives deployment. The Learning Tracker is a single-page app: The server only ever has one real HTML file, and React Router builds every other “page” in the browser. Visit / and the host serves index.html; the router takes it from there.

But visit /courses/react-basics directly — a bookmark, a shared link, a reload — and a static host looks for a file at that path, finds nothing, and returns a 404. The fix is a fallback rule: Tell the host to serve index.html for any path it doesn’t recognize, and let the router sort out the URL. Create public/_redirects (Netlify reads this file):

/*  /index.html  200

Anything in public is copied verbatim into dist at build time, so this rule ships with your app. The rule reads: For any path (/*), serve /index.html with a 200 OK. Rebuild so the file lands in dist:

npm run build

The build output looks the same as before — there’s nothing new to see on screen. The point is that _redirects now sits inside dist, ready to ship alongside your app.

Previewing the Production Build

Before deploying, run the built files locally to catch anything the dev server hid. Vite has a command for exactly this:

npm run preview

It serves your built dist files locally over HTTP — a close stand-in for a host, though not an exact one. Open it and click around:

The production build running locally through vite preview — the same bundled app you'll upload.
The production build running locally through vite preview — the same bundled app you'll upload.

This is the real, minified app — the summary line, search, favorites, the plan, navigation — all intact. Problems you catch here are problems you’d have shipped. Your host adds its own routing, headers and caching on top, so the final word comes from testing the live site — but if the bundle is broken here, deploying won’t fix it. Stop the preview with Ctrl-C when you’re satisfied.

Deploying to Netlify

Time to put it online. Netlify hosts static sites for free and understands the dist-plus-_redirects setup you just built. The fastest path needs no account setup beyond signing in:

  1. Sign in to Netlify and find the Sites area.
  2. Drag your project’s dist folder onto the deploy drop zone.
  3. Netlify uploads the files and hands you a live URL like https://your-app-name.netlify.app.

That’s a real, shareable address serving your app worldwide. For ongoing work, Netlify can instead connect to a Git repository and rebuild on every push — set the build command to npm run build and the publish directory to dist — but a drag-and-drop deploy is all you need to see the Learning Tracker live.

Note: Hosting dashboards change their wording and layout often. If a button name here doesn’t match what you see, look for the equivalent “deploy” or “add new site” action — the concepts (upload a build folder, get a URL) are stable even when the buttons move.

Verifying the Live App

A deploy isn’t done until you’ve checked it like a user. Open your Netlify URL and walk the list:

A deployment checklist: verify the build, the fallback, and the key journeys on the live URL.
A deployment checklist: verify the build, the fallback, and the key journeys on the live URL.

The crucial one is the fallback: Visit your-app.netlify.app/courses/react-basics directly. Thanks to the _redirects rule, the router loads, and the details page renders instead of a 404. Then exercise the real journeys on the live site — search, favorite a course, add one to your plan, reload to confirm it persisted, and try a nonsense path to see the Not Found page. When every line is green, you’ve shipped it.

Challenge: Add a Plan Total

The catalog summarizes itself; give My Learning the same treatment. Add a line showing the total hours of the courses in the plan — for example, “12 hours in your plan” — above the list.

A few hints:

  • The MyLearning component already receives items and courses. Sum the durationHours of each planned course, treating a missing duration as zero.
  • This runs over a handful of items, so it’s cheap — derive it inline and don’t memoize. Measure if you’re unsure; that’s the lesson.
  • Match the look of the existing progress line.

You’ll find a complete solution in the challenge folder of this chapter’s materials.

Key Points

  • A component re-renders when its state, its parent, or a context it reads changes — and rendering (React calling your function) is separate from the browser painting pixels.
  • Measure before optimizing. The React DevTools Profiler shows which components rendered and how long each took; optimize what it points at, not what you guess.
  • Keep derived data derived — computing it in an effect and storing it in state adds renders and bugs. If it’s expensive, memoize instead.
  • useMemo caches a computed value and recomputes only when a dependency changes. Depend on the underlying state, not on arrays rebuilt each render.
  • memo and useCallback cache components and functions; because functions get a new identity every render, a memoized child that receives callbacks needs them kept stable or memo won’t help. Reach for them only when a measurement demands it.
  • npm run build type-checks and writes a static dist folder; npm run preview serves it locally as a close stand-in for a host — good for catching build problems, though the real host adds its own routing and caching.
  • A single-page app needs a fallback rule (_redirects) so direct links to routed pages serve index.html instead of a 404.
  • Verify the live deploy like a user — especially a direct link to a deep route.

Where to Go From Here?

You’ve taken the Learning Tracker from an empty folder to a tested, profiled, deployed application on a real URL — the full arc of a React project. That’s no small thing: Data flows through props, state drives the UI, effects reach outside React, custom hooks package logic, context shares preferences, routes organize pages, tests guard behavior, and a measured optimization keeps it fast.

This chapter closes the journey through the Learning Tracker, but not your React one — modern features like Actions, useOptimistic and Suspense build on the explicit patterns you’ve learned here, and are worth exploring next. For now, though, take the win: Your app is live. Send someone the link.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.