Performance & Error Handling
The senior module. Notice how often the right answer starts with "measure first" — that's exactly what they're listening for.
Error Boundaries
A class component that catches errors thrown while rendering its children and shows a fallback UI instead of a blank white screen.
Without one, a single error anywhere unmounts the entire React tree — the user sees a blank page and has no idea what happened.
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError(error) {
return { hasError: true }; // 1. switch to the fallback UI
}
componentDidCatch(error, info) {
logToServer(error, info.componentStack); // 2. report it
}
render() {
if (this.state.hasError) return <h3>Something went wrong. <button onClick={...}>Retry</button></h3>;
return this.props.children;
}
}
<ErrorBoundary><Dashboard /></ErrorBoundary>
Q65 · What they catch — and what they don't
| ✅ Caught | ❌ NOT caught |
|---|---|
| Errors during render | Event handlers (onClick) — use try/catch |
| Errors in lifecycle methods | Async code — setTimeout, promises, API calls |
| Errors in child constructors | Server-side rendering |
| Errors thrown in the boundary itself |
"Do error boundaries replace try/catch around API calls?" No. An API
call is asynchronous — by the time the promise rejects, the render is long finished, so the
boundary never sees it. API errors need try/catch and an error state in the UI
(Q80). The boundary is the last-resort net for rendering crashes, like
user.name when user is null.
Not one giant boundary around the whole app — then any error still blanks everything. Wrap
each route, plus risky widgets like charts and third-party embeds, so a
broken dashboard tile doesn't take the navigation down with it. In function components you
use the react-error-boundary package, since the feature itself still requires a
class.
An error boundary is a class component using
getDerivedStateFromError and
componentDidCatch that catches errors thrown while rendering its children and
shows a fallback instead of unmounting the whole tree. It catches render, lifecycle and
constructor errors — not event handlers, not async code, not itself — so it doesn't replace
try/catch around API calls, which are asynchronous and need their own error state. I wrap
each route and any risky widget separately, and log the error with the component stack so it
can be traced.
Why are keys required in lists — and why not the array index?
A key gives each item a stable identity so React can match it across renders. An index isn't an identity — it changes when the list changes.
The visible bug: type into the first row's input, insert a new item at the top, and your text is now attached to the wrong row — because React reused DOM node 0 for a different item.
Tick the box next to "Brands" in both lists, then press Add at top. In the index-keyed list the tick jumps to the new row; in the id-keyed list it stays with "Brands".
The rules
- Keys must be unique among siblings, not globally.
- Use a stable id from the data — a database id, not
Math.random(), which changes every render and forces a full rebuild. - Index is acceptable only when the list is static, never reordered, filtered or added to.
- The key goes on the outermost element inside
map, and it isn't readable as a prop.
Keys give list items a stable identity so React's reconciliation can match each element to
the same item between renders instead of matching by position. With the array index as the
key, inserting or reordering shifts every index, so React thinks every row changed — it
rewrites DOM it didn't need to, and worse, component state and input values stay attached to
the position rather than the item, so they end up on the wrong row. Index keys are only safe
for a static list that's never reordered or filtered.
Lazy loading, code splitting and Suspense
Code splitting breaks the bundle into chunks, lazy loading downloads a chunk only when it's needed, and Suspense shows a fallback while it arrives.
const ReportsPage = React.lazy(() => import("./pages/ReportsPage"));
<Suspense fallback={<Spinner />}>
<Routes>
<Route path="/reports" element={<ReportsPage />} />
</Routes>
</Suspense>
| Term | What it is | Who does it |
|---|---|---|
| Code splitting | Splitting JS into chunks | The bundler (Vite/webpack), triggered by import() |
| Lazy loading | Loading a chunk on demand | React.lazy |
| Suspense | The loading UI while it's fetched | React |
1. Routes — the biggest win, one line each. 2. Heavy libraries — a chart library, a rich-text editor, a PDF viewer; don't ship a 600 KB editor to users who never open it. 3. Modals and dialogs that are rarely opened. Also pair a lazy route with an error boundary, because a failed chunk download throws.
Code splitting is the bundler breaking the JavaScript into separate chunks, triggered by a
dynamic
import(). React.lazy wraps that so a component is only
downloaded when it's first rendered, and Suspense provides the fallback UI while
the chunk is in flight. The standard use is per route, so the initial bundle only contains
what's needed for the first screen, plus splitting heavy libraries like chart or editor
packages. I'd wrap lazy routes in an error boundary too, since a failed chunk load throws
during render.
Optimising large lists (and what FlatList is)
Virtualization — render only the rows currently visible, not all ten thousand.
import { FixedSizeList } from "react-window";
<FixedSizeList height={600} itemCount={items.length} itemSize={48} width="100%">
{({ index, style }) => <div style={style}>{items[index].name}</div>}
</FixedSizeList>
The full checklist
- Paginate on the server first — the cheapest fix is not sending 10,000 rows.
- Virtualize with react-window or TanStack Virtual when you genuinely need a long scroll.
- Stable keys — database ids, never the index (Q76).
- Memoize the row with
React.memo+useCallbackfor its handlers. - No inline objects or arrow functions in the row props — they break the memo.
- Debounce search and filter inputs (Q98).
Q78 · FlatList is React Native's built-in virtualized list — the same idea, but
mobile. It takes data, renderItem and keyExtractor, and
renders only what's on screen. Native has no DOM, so it ships virtualization in the box; on the
web you add react-window yourself.
First I'd avoid the problem by paginating on the server. If a long list is genuinely
needed, I virtualize it with react-window so only the visible rows exist in the DOM — that
turns ten thousand nodes into about twenty and keeps scrolling smooth. On top of that: stable
database ids as keys, the row wrapped in
React.memo with useCallback
handlers so it doesn't re-render when the parent does, and no inline objects in the props
which would break that memo. In React Native the equivalent is FlatList, which
is virtualized by default.
How do you optimise React performance and debug a slow app?
Measure first. Find where the time actually goes, fix the cause, and only then memoize.
Step 1 — Measure (Q79)
| Tool | Tells you |
|---|---|
| React DevTools Profiler | Which components render, how often, how long, and why ("Why did this render?") |
| "Highlight updates" setting | Flashing borders showing what re-renders as you interact — the fastest way to spot waste |
| Network tab | Slow endpoints, request waterfalls, a list page firing one call per row (the frontend N+1) |
| Lighthouse / bundle analyzer | Bundle size, unused libraries, images, slow first paint |
"Most React 'performance problems' turn out to be one of three things: a slow API, rendering too much data at once, or re-rendering a large tree on every keystroke. Memoization is usually the last fix, not the first."
Step 2 — Fix the cause
- Move state down. If a search input at the top of a page re-renders the whole table, push that state into a smaller component.
- Fix keys. Index keys cause whole-list rebuilds.
- Stop creating objects in the parent that break children's memo.
- Memoize the expensive bits —
React.memoon heavy children,useMemoon real calculations,useCallbackfor their handlers. - Virtualize long lists, debounce inputs.
- Code-split routes and heavy libraries, and lazy-load images.
- Cache server data with React Query instead of refetching on every mount.
I start by measuring, not guessing. The React Profiler with "highlight updates" shows what
re-renders and why, the Network tab shows slow endpoints and request waterfalls, and a bundle
analyzer shows what's bloating the first load. Then I fix the cause rather than papering over
it — usually state sitting too high in the tree, index keys rebuilding lists, or objects
recreated in a parent breaking a child's memo. After that come the tools:
React.memo
and useMemo where the profiler showed cost, virtualization for long lists,
debouncing for search inputs, route-level code splitting, and React Query so data isn't
refetched on every mount. In my experience most 'React is slow' issues are really a slow API
or rendering far too many rows at once.