Skip to content

Advanced React Behavior and Tests

Step 30 of 45

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:

  1. Explain what causes a React component to re-render and when re-rendering is a problem.
  2. Measure before optimizing, using the browser Performance panel and React DevTools Profiler.
  3. Apply React.memo, useMemo, and useCallback correctly, and explain what the React Compiler changes.
  4. Keep an input responsive with useDeferredValue or useTransition, and render long lists efficiently with stable keys, pagination, or windowing.
  5. Reduce what the browser must download with code splitting (lazy, Suspense) and image optimization.
  6. Read the three Core Web Vitals and name one fix for each.
  7. 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:

  1. Reproduce the slow action (typing in the search box).
  2. Record a profile while you do it.
  3. Find the longest tasks or the components with the largest render time.
  4. Change one thing.
  5. 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:

  1. 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.
  2. Pagination or "Load more". Show 20 items at a time. It also helps users, because 400 rows are hard to scan.
  3. Windowing (virtualization). Render only the rows visible in the viewport, using a library such as react-window or @tanstack/react-virtual. This is worth it for thousands of rows, not for dozens.
  4. Move state down. Keep the query state 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 width and height (or aspect-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 srcset and sizes so 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.

  1. Measure. The Profiler confirms rows are the cost, not the network. Production build, 4x CPU throttling, 300 ms commit time.
  2. Find the cause. Rows re-render because the parent re-renders, and each receives a fresh onReserve arrow function.
  3. Cheapest fix first. Move the search draft state into SearchBox and apply it on a short delay or on submit. This removes most renders without any memoization.
  4. If live filtering is required. Wrap WorkshopRow in React.memo, pass a stable onReserve created with useCallback, and feed the list with useDeferredValue(query).
  5. 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.memo immediately: it adds comparison cost everywhere, and without a stable callback it changes nothing for the rows.
  • Debounce with setTimeout only: 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

  1. A live demo link (static hosting such as GitHub Pages, Netlify, or Vercel) that works on a phone.
  2. A clean repository with meaningful commit messages, no committed node_modules, and a .gitignore.
  3. 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.
  4. Working scripts: npm install, npm run dev, npm test, npm run build.
  5. Tests for the important behavior (booking form, reducer), as in the previous lesson.
  6. Accessibility basics: labels, keyboard use, visible focus, sufficient contrast, meaningful alt text.
  7. 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).

![Screenshot of the catalog on mobile and desktop](docs/screenshot.png)

**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 useMemo everywhere 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 with useMemo or useCallback when a memoized child depends on them.
  • Wrong dependency arrays. A missing dependency in useCallback or useMemo gives stale values; the linter rule react-hooks/exhaustive-deps helps.
  • 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.

  1. 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.
  2. 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.
  3. Lazy-load the chart tab with lazy and Suspense. This reduces the first load by 300 KB. Confirm in the Network panel and with the LCP value in Lighthouse.
  4. 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, and useCallback help, and why unstable props defeat them.
  • I can keep an input responsive with useDeferredValue or useTransition.
  • 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