← React, from components to interview-ready

Lesson 4 of 5

Performance and rendering: what makes React apps slow and how to fix it

3 min read

Most React performance problems come from three causes: rendering too much, rendering too often, or doing too much work per render. Always measure first with the React DevTools Profiler ("Highlight updates when components render") before optimising.

React.memo

React.memo(Component) skips re-rendering if the props are shallowly equal to last time.

jsx
const Row = React.memo(function Row({ item, onSelect }) {
  return <li onClick={() => onSelect(item.id)}>{item.name}</li>;
});

function List({ items }) {
  const [selected, setSelected] = useState(null);
  // Without useCallback, a new function every render breaks Row's memo
  const onSelect = useCallback((id) => setSelected(id), []);
  return <ul>{items.map((i) => <Row key={i.id} item={i} onSelect={onSelect} />)}</ul>;
}

Shallow comparison means objects, arrays and functions created during render are "new" each time. That's why React.memo, useCallback and useMemo usually go together.

Cheaper wins before memo

  1. Move state down. If only a small part of the page changes (a text input), put that state in a small component so the rest doesn't re-render.
  2. Lift content up / pass children. A component that changes often can receive the expensive part as children; children created by the parent don't re-render when the wrapper's state changes.
  3. Avoid creating new objects in props for memoized children.
  4. Split context so a change in one value doesn't re-render every consumer.

Big lists: virtualization

Rendering 10,000 rows creates 10,000 DOM nodes. Virtualization (react-window, TanStack Virtual) renders only the rows visible in the viewport, plus a small buffer. Mention this for any "large table" or "infinite feed" question, along with pagination or infinite scroll with an IntersectionObserver.

Code splitting and lazy loading

jsx
const AdminPanel = React.lazy(() => import("./AdminPanel"));

<Suspense fallback={<Spinner />}>
  {isAdmin && <AdminPanel />}
</Suspense>

The admin code is downloaded only when needed. Route-based splitting (each page its own bundle) is the biggest, easiest win. Frameworks like Next.js do it automatically.

Concurrent features

  • useTransition marks an update as non-urgent so typing stays responsive while a heavy list re-renders: startTransition(() => setFilter(value)).
  • useDeferredValue(value) gives you a lagging copy of a value for expensive children.

Debouncing and throttling

For search-as-you-type, debounce the request (wait until typing stops). For scroll and resize handlers, throttle (run at most every N ms). Both reduce work and network calls.

Network and assets

Performance is not only rendering:

  • Cache server data (TanStack Query, SWR) instead of refetching on every mount.
  • Compress and size images, use modern formats (WebP/AVIF) and loading="lazy".
  • Keep bundles small: check what a library adds before installing it, import only what you use.
  • Server rendering (SSR) or static generation improves first paint and SEO.

A good interview answer structure

When asked "How would you optimise a slow React page?":

  1. Reproduce and measure (Profiler, Lighthouse, network tab).
  2. Find the cause: unnecessary re-renders, expensive calculations, big lists, large bundles, slow APIs.
  3. Apply the matching fix: move state, memoize, virtualize, split code, cache data.
  4. Measure again to confirm.

Check yourself

  • Why does passing an inline arrow function break React.memo?
  • What is virtualization and when do you need it?
  • What does useTransition do?

Sign up free to save your progress.