Performance and a Portfolio Interface
Introduction
This is lesson 6 of chapter 6, the last lesson of the "Advanced React" part of the Meta Front-End Developer pathway (course 6, domain advanced-react). You can now build components, manage complex state, write hooks, reuse behavior and test the result. One question remains before you move on to design and the capstone: is the interface fast, and can you show it convincingly to someone else?
Performance is not only about milliseconds. A booking page that freezes for half a second while you type in a search box feels broken, and a page that loads a 2 MB script on a phone loses visitors before they see anything. At the same time, a developer's work is judged through a portfolio: a small number of polished, explained projects. This lesson covers both. First you learn how to measure and fix the most common React performance problems. Then you assemble a portfolio-ready version of the Harbor Workshops booking interface, with a README, a clean repository, and an honest review checklist.
The scenario is original. Harbor Workshops is a fictional community center. Its catalog lists 400 workshops, and the first version of the page feels slow.
Learning objectives
By the end of this lesson you can:
- Explain what causes a React component to re-render and when re-rendering is a problem.
- Measure before optimizing, using the browser Performance panel and React DevTools Profiler.
- Apply
React.memo,useMemo, anduseCallbackcorrectly, and explain what the React Compiler changes. - Keep an input responsive with
useDeferredValueoruseTransition, and render long lists efficiently with stable keys, pagination, or windowing. - Reduce what the browser must download with code splitting (
lazy,Suspense) and image optimization. - Read the three Core Web Vitals and name one fix for each.
- Prepare a portfolio project: README, scripts, deployment, and a self-review checklist.
1. Why components re-render
A React component is a function. React calls it again (re-renders) when:
- its own state changes,
- its parent re-renders (by default, all children re-render too, even if their props are identical),
- a context it reads changes.
Re-rendering means calling the function and comparing the result with the previous output. Most of the time this is cheap, and React only touches the real DOM when something actually changed. So a re-render is not a bug. It becomes a problem only when the render work is expensive (large lists, heavy calculations) or happens very often (every keystroke).
Here is the first version of the Harbor Workshops catalog:
function Catalog({ workshops }) {
const [query, setQuery] = useState('');
const visible = workshops.filter(w =>
w.title.toLowerCase().includes(query.toLowerCase())
);
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} aria-label="Search workshops" />
<ul>
{visible.map(w => (
<WorkshopRow key={w.id} workshop={w} onReserve={() => reserve(w.id)} />
))}
</ul>
</>
);
}
Every keystroke updates query. Catalog re-renders, filters 400 items, and re-renders up to 400 WorkshopRow components. On a fast laptop nothing is visible. On an older phone, typing lags. The cause is clear, but you should still measure before changing anything.
2. Measure first
Optimizing without measuring wastes time and often makes code harder to read for no gain. Use two tools.
| Tool | What it shows | Typical question it answers |
|---|---|---|
| React DevTools Profiler | Which components rendered, why, and how long each took | "Why did WorkshopRow render when I typed?" |
| Browser Performance panel | Main-thread activity, long tasks, layout, paint | "Is JavaScript, layout, or network the bottleneck?" |
| Lighthouse / PageSpeed Insights | Lab scores and Core Web Vitals hints | "Is the page slow to load on mobile?" |
| Network panel | Size and timing of each request | "Which file is too big?" |
A simple workflow:
- Reproduce the slow action (typing in the search box).
- Record a profile while you do it.
- Find the longest tasks or the components with the largest render time.
- Change one thing.
- Record again and compare.
Always profile a production build (npm run build then npm run preview with Vite). Development mode adds extra checks and is noticeably slower, so its numbers mislead you.
Exam tip: when a question describes a slow interface and asks for the first step, "measure with a profiler" is almost always better than "wrap everything in React.memo".
3. Memoization: React.memo, useMemo, useCallback
Memoization means remembering a previous result to skip repeated work.
React.memo
React.memo wraps a component so it re-renders only if its props changed (shallow comparison).
const WorkshopRow = React.memo(function WorkshopRow({ workshop, onReserve }) {
return (
<li>
<h3>{workshop.title}</h3>
<button onClick={onReserve}>Reserve</button>
</li>
);
});
This alone does nothing useful in the catalog above. Why? Because onReserve={() => reserve(w.id)} creates a new function on every render. A new function is never equal (===) to the old one, so the shallow comparison always says "props changed".
useCallback
useCallback returns the same function between renders as long as its dependencies are unchanged.
const handleReserve = useCallback(id => reserve(id), [reserve]);
// ...
<WorkshopRow key={w.id} workshop={w} onReserve={handleReserve} />
Now WorkshopRow receives the same workshop object and the same function, and the shallow comparison passes. To make this work, the row must call onReserve(workshop.id) itself, because the callback no longer closes over w.
useMemo
useMemo remembers the result of a calculation until its dependencies change.
const visible = useMemo(
() => workshops.filter(w => w.title.toLowerCase().includes(query.toLowerCase())),
[workshops, query]
);
Here the benefit is small, because query changes on each keystroke anyway. useMemo helps when the component re-renders for other reasons (for example, a modal opening) while the expensive inputs stayed the same.
Rules of thumb
| Tool | Use it when | Do not use it when |
|---|---|---|
React.memo |
A child is expensive to render and often receives identical props | The child is tiny, or its props change every time anyway |
useCallback |
A function is passed to a memoized child or used as an effect dependency | The function is only used by plain DOM elements |
useMemo |
A calculation is measurably slow, or a value must keep a stable identity | The calculation is trivial |
Memoization has a cost: extra memory, extra comparisons, and more code to read. It never changes what the app displays. If removing a useMemo changes behavior, the code has a bug.
What the React Compiler changes
The React Compiler (stable as version 1.0 in October 2025) is a build-time tool that analyzes components and adds memoization automatically. The React team's guidance is: for new code, rely on the compiler and keep useMemo and useCallback for cases where you need precise control, such as a value used as an effect dependency. For existing code, leave current memoization in place, or test carefully before removing it. The compiler is opt-in, and it assumes you follow the Rules of React (pure render functions, no mutation of props or state). The Meta course teaches the manual hooks, and exams based on it still ask about them, so you must understand them even if a project uses the compiler.
4. Keep typing responsive
Sometimes the expensive part is not avoidable: you must filter a large list. React offers tools that keep the input responsive by making the heavy update lower priority.
useDeferredValue
function Catalog({ workshops }) {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} aria-label="Search workshops" />
<WorkshopList workshops={workshops} query={deferredQuery} />
</>
);
}
The input updates immediately with query. The list receives deferredQuery, which lags behind during fast typing, and React renders it in the background. For this to help, WorkshopList should be wrapped in React.memo, so it skips rendering when deferredQuery has not changed yet.
useTransition
useTransition marks a state update as non-urgent:
const [isPending, startTransition] = useTransition();
function handleChange(e) {
const value = e.target.value;
setQuery(value); // urgent: keeps the input responsive
startTransition(() => setFilter(value)); // non-urgent: the list can wait
}
isPending lets you show a subtle "Updating…" hint. Use useTransition when you control the state setter, and useDeferredValue when you receive the value as a prop or from another hook.
5. Render long lists well
For the 400-item catalog, several techniques apply, from simplest to most advanced:
- Stable keys. Use
key={workshop.id}, never the array index when the list can be filtered, sorted, or reordered. Index keys make React reuse the wrong component instance, which causes lost input text and wasted work. - Pagination or "Load more". Show 20 items at a time. It also helps users, because 400 rows are hard to scan.
- Windowing (virtualization). Render only the rows visible in the viewport, using a library such as
react-windowor@tanstack/react-virtual. This is worth it for thousands of rows, not for dozens. - Move state down. Keep the
querystate in the search component rather than in a high-level parent, so fewer components re-render when it changes.
The last technique is often the best first move and costs nothing:
// Before: App owns `query`, so the whole page re-renders on each keystroke
// After: SearchBox owns its draft state and reports the committed value
function SearchBox({ onSearch }) {
const [draft, setDraft] = useState('');
return (
<form onSubmit={e => { e.preventDefault(); onSearch(draft); }}>
<label htmlFor="q">Search</label>
<input id="q" value={draft} onChange={e => setDraft(e.target.value)} />
</form>
);
}
6. Load less code and fewer bytes
Runtime speed is half the story. Before anything renders, the browser must download, parse, and run your JavaScript.
Code splitting with lazy and Suspense
A bundler such as Vite can split your app into several files. With React.lazy, a page's code is downloaded only when it is needed:
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
const AdminReport = lazy(() => import('./pages/AdminReport'));
function App() {
return (
<Suspense fallback={<p role="status">Loading…</p>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/admin/report" element={<AdminReport />} />
</Routes>
</Suspense>
);
}
Only visitors who open the report download its code. Splitting by route is the most common and safest choice.
Images
Images are usually the heaviest part of a page.
- Use modern formats (WebP or AVIF) and the right dimensions; do not ship a 4000-pixel photo for a 300-pixel card.
- Set
widthandheight(oraspect-ratio) so the browser reserves space and the layout does not jump. - Use
loading="lazy"for images below the fold, but not for the main image at the top, which should load early. - Provide
srcsetandsizesso small screens download smaller files.
<img
src="/img/pottery-640.webp"
srcset="/img/pottery-320.webp 320w, /img/pottery-640.webp 640w, /img/pottery-1280.webp 1280w"
sizes="(min-width: 800px) 33vw, 100vw"
width="640" height="427" alt="Hands shaping clay on a pottery wheel" loading="lazy">
Other savings
Remove unused dependencies, import only what you need (import debounce from 'lodash/debounce' rather than the whole library), enable compression on the server, and cache static files with long-lived headers.
7. Core Web Vitals
Google's Core Web Vitals measure real user experience. They are a common vocabulary in interviews and job descriptions.
| Metric | Measures | "Good" threshold | Typical fix |
|---|---|---|---|
| LCP (Largest Contentful Paint) | How fast the main content appears | 2.5 s or less | Optimize the hero image, reduce render-blocking code, use caching |
| INP (Interaction to Next Paint) | How fast the page responds to clicks, taps, and keys | 200 ms or less | Break up long tasks, defer heavy work, use transitions |
| CLS (Cumulative Layout Shift) | How much the layout moves unexpectedly | 0.1 or less | Reserve space for images, fonts, and banners |
INP replaced First Input Delay (FID) as a Core Web Vital in March 2024. Thresholds are measured at the 75th percentile of real visits. Lab tools such as Lighthouse give estimates; field data from real users is the final judge.
Worked example: fixing a laggy search
Problem. In the Harbor Workshops catalog, typing in the search box on a mid-range phone takes about 300 ms per keystroke. The Profiler shows that all 400 WorkshopRow components render on each keystroke, even though only the filtered result changes.
Reasoning, step by step.
- Measure. The Profiler confirms rows are the cost, not the network. Production build, 4x CPU throttling, 300 ms commit time.
- Find the cause. Rows re-render because the parent re-renders, and each receives a fresh
onReservearrow function. - Cheapest fix first. Move the search draft state into
SearchBoxand apply it on a short delay or on submit. This removes most renders without any memoization. - If live filtering is required. Wrap
WorkshopRowinReact.memo, pass a stableonReservecreated withuseCallback, and feed the list withuseDeferredValue(query). - Verify. Profile again: commit time falls to roughly 40 ms, and typing feels immediate.
Solution (summary).
const WorkshopRow = React.memo(function WorkshopRow({ workshop, onReserve }) {
return (
<li>
<h3>{workshop.title}</h3>
<button onClick={() => onReserve(workshop.id)}>Reserve</button>
</li>
);
});
function Catalog({ workshops, reserve }) {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const handleReserve = useCallback(id => reserve(id), [reserve]);
const visible = useMemo(
() => workshops.filter(w => w.title.toLowerCase().includes(deferredQuery.toLowerCase())),
[workshops, deferredQuery]
);
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} aria-label="Search workshops" />
<ul>
{visible.map(w => <WorkshopRow key={w.id} workshop={w} onReserve={handleReserve} />)}
</ul>
</>
);
}
Why the other choices are weaker.
- Wrap every component in
React.memoimmediately: it adds comparison cost everywhere, and without a stable callback it changes nothing for the rows. - Debounce with
setTimeoutonly: it reduces the work but makes the input feel delayed, because the visible text itself waits. Deferring the list while updating the input immediately is better. - Add a virtualization library at once: with 400 simple rows, it adds complexity that pagination or deferral handles more simply. Keep virtualization for thousands of rows.
8. Build a portfolio-ready interface
A portfolio is a short list of projects that prove you can ship. A reviewer, such as a recruiter or a hiring engineer, may spend two minutes on each project. Make those minutes count.
What a good portfolio project contains
- A live demo link (static hosting such as GitHub Pages, Netlify, or Vercel) that works on a phone.
- A clean repository with meaningful commit messages, no committed
node_modules, and a.gitignore. - A README that answers, in order: what it is, a screenshot, how to run it, what technologies it uses, what you decided and why, what you would do next.
- Working scripts:
npm install,npm run dev,npm test,npm run build. - Tests for the important behavior (booking form, reducer), as in the previous lesson.
- Accessibility basics: labels, keyboard use, visible focus, sufficient contrast, meaningful alt text.
- Honesty: your own code, with credit for libraries, images, and tutorials you used.
A README skeleton
# Harbor Workshops
A responsive catalog and booking interface for community workshops (React, Vite).

**Live demo:** https://example.org/harbor-workshops
## Features
- Search and filter 400 workshops with a responsive input
- Accessible booking form with validation and clear errors
- Cart state managed with a reducer and context
## Run locally
npm install
npm run dev
npm test
## Decisions
- Reducer for the cart, because several actions change related values
- Route-level code splitting to keep the first load small
- Measured with the Profiler before adding memoization
## Next steps
- Server-side persistence of bookings
- End-to-end tests for the full booking flow
Self-review before sharing
| Area | Question |
|---|---|
| Function | Does the main flow (browse, select, book) work without errors in the console? |
| Responsive | Does it work at 360 px, 768 px and 1280 px wide? |
| Accessibility | Can you complete the flow with the keyboard only? Do images have alt text? |
| Performance | Is the production build checked in Lighthouse? Is there a layout shift? |
| Code | Are components small and named well? Is there dead code or a leftover console.log? |
| Docs | Can a stranger run the project from the README alone? |
| Security | Are there no secrets or API keys in the repository history? |
Common pitfalls and exam tips
- Optimizing by habit. Adding
useMemoeverywhere is not a performance strategy. Measure first. - Unstable props defeat
React.memo. Inline objects (style={{...}}), arrays, and arrow functions create new identities each render. Stabilize them withuseMemooruseCallbackwhen a memoized child depends on them. - Wrong dependency arrays. A missing dependency in
useCallbackoruseMemogives stale values; the linter rulereact-hooks/exhaustive-depshelps. - Index keys on dynamic lists. They cause incorrect state reuse.
- Lazy loading the hero image. It delays LCP.
- Measuring in development mode. The numbers are not representative.
- Exam tip: know the difference between urgent and non-urgent updates (
useTransition), between render cost and download cost (memoization versus code splitting), and between the three Web Vitals.
Mini case study: the sluggish dashboard
A volunteer builds a staff dashboard for Harbor Workshops. It shows a table of 2,000 bookings, a chart, and a filter bar. Staff report that selecting a filter checkbox takes about one second to react. The developer proposes wrapping all components in React.memo. The profile shows that the filter state lives in App, so changing a checkbox re-renders the table of 2,000 rows, and that the chart code (a 300 KB library) loads on the first page even though only 20% of visitors open the chart tab.
Tasks. Propose a plan with at least three actions, in priority order, and say what you would measure to confirm each.
Commented solution.
- Move the filter state closer to the filter bar and keep committed filters in the table's parent. Checkbox clicks then re-render only the bar until the user applies the filter. Confirm with the Profiler: the table should no longer appear in the render list while checking boxes.
- Render the table with pagination or windowing. Thousands of rows are the real cost; showing 50 at a time cuts render and layout work. Confirm with the commit duration and the number of DOM nodes.
- Lazy-load the chart tab with
lazyandSuspense. This reduces the first load by 300 KB. Confirm in the Network panel and with the LCP value in Lighthouse. - Only then, memoize the row component with a stable callback if the profile still shows unnecessary row renders.
Blanket React.memo is last because it treats a symptom, adds comparison work everywhere, and does not reduce the size of the download.
Key terms
- Re-render: React calling a component function again to compute new output.
- Memoization: remembering a previous result to avoid repeated work.
React.memo: wraps a component to skip rendering when props are shallow-equal.useMemo/useCallback: remember a value or a function between renders.- React Compiler: a build-time tool that adds memoization automatically.
- Transition: a state update marked as non-urgent with
useTransition. - Virtualization (windowing): rendering only the visible rows of a long list.
- Code splitting: dividing the bundle so code loads on demand.
- Core Web Vitals: LCP, INP, and CLS.
- Profiler: a tool that records which components rendered and for how long.
Summary checklist
- I can explain why a component re-renders and why that is not always a problem.
- I measure with the Profiler or Performance panel on a production build before optimizing.
- I know when
React.memo,useMemo, anduseCallbackhelp, and why unstable props defeat them. - I can keep an input responsive with
useDeferredValueoruseTransition. - I use stable keys and choose pagination, windowing, or moving state down for long lists.
- I split code by route and optimize images (size, format, dimensions, lazy loading).
- I can name LCP, INP, and CLS with their targets and one fix each.
- My portfolio project has a live demo, a README, working scripts, tests, and a self-review.
Official sources
- Meta Front-End Developer Professional Certificate (opens in a new tab)
- Course 6: Advanced React (opens in a new tab)
- React:
memo(opens in a new tab) - React:
useMemo(opens in a new tab) anduseCallback(opens in a new tab) - React:
useTransition(opens in a new tab) anduseDeferredValue(opens in a new tab) - React:
lazy(opens in a new tab) - React Compiler v1.0 (opens in a new tab)
- web.dev: Core Web Vitals (opens in a new tab)
