Performance, Error Handling, and Maintainable Architecture
Memoization, error boundaries, and refactoring an oversized component into a maintainable structure — brought together in a real local refactor of a growing project.
What you'll learn
- Explain what memoization actually trades away, and when that trade is worth it
- Explain what an error boundary catches and what it deliberately does not catch
- Refactor an oversized component into a maintainable structure on your own machine
Prerequisites
Explanation
Memoization trades memory and comparison cost for avoided recomputation. useMemo(() => expensiveFilter(items, query), [items, query]) skips re-running expensiveFilter on a render where neither items nor query changed, returning the previously cached result instead — but React still has to store that cached value and compare the dependency array every render, which is itself not free. For a genuinely expensive computation (filtering or sorting thousands of items) or to preserve a stable reference for a child that's specifically optimized to skip re-rendering when its props are unchanged, this is a real, worthwhile trade. Memoizing every value "just in case" is not a worthwhile trade — for a cheap computation, the bookkeeping cost of memoization can exceed the cost of simply redoing the work, and the practical guidance holds: measure first (React's DevTools Profiler shows real render costs), then memoize the specific, proven bottleneck, not everything preemptively.
An error boundary catches rendering errors in its child tree and shows a fallback UI instead of the entire app crashing to a blank white screen. It's a class component (the one place React still requires one) implementing static getDerivedStateFromError or componentDidCatch. Critically, it does not catch errors in event handlers, asynchronous code, or errors during its own rendering — those need their own explicit try/catch or .catch() handling, exactly as they would in plain JavaScript. An error boundary's job is narrow and specific: prevent one broken subtree's rendering error from taking down everything around it.
An oversized component is a maintainability problem the same way an oversized function is anywhere else: too many responsibilities crammed into one place, too much local state to reason about together, too much to hold in your head to make a safe change. The fix follows directly from the very first lesson's component-thinking skill, now applied under real pressure — recognizing which pieces of a 300-line component are actually independent responsibilities, and extracting each into its own component or custom hook, exactly the two reuse tools from earlier in this course.
This lesson's guided local lab is a real refactor: take a deliberately oversized component and break it apart using everything from this course — composition, custom hooks, an error boundary, and a measured (not guessed) performance fix.
Example
Modeling the cost/benefit of memoization directly -- counting how many times an expensive function actually runs, with and without a memoization guard.
function expensiveFilter(items, query) {
return items.filter((i) => i.includes(query));
}
function withoutMemo(items, query, renderCount) {
let calls = 0;
for (let i = 0; i < renderCount; i++) {
expensiveFilter(items, query); // recomputes every single render
calls++;
}
return calls;
}
function withMemo(items, query, renderCount) {
let calls = 0;
let cache = null;
let cachedItems = null;
let cachedQuery = null;
for (let i = 0; i < renderCount; i++) {
if (items !== cachedItems || query !== cachedQuery) {
expensiveFilter(items, query);
calls++;
cachedItems = items;
cachedQuery = query;
}
}
return calls;
}
const items = ["react", "redux", "remix"];
console.log(withoutMemo(items, "re", 5)); // 5 -- recomputed every render
console.log(withMemo(items, "re", 5)); // 1 -- computed once, reused for the other 4Try it yourself
Change the query between renders (simulate a user typing) and see how many times withMemo actually recomputes.
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Guided exercise
Guided exercise
Write catchesThisError(errorSource) that returns true only for 'render' (what an error boundary catches), and false for 'event-handler', 'async-callback', and 'own-render' (an error boundary's OWN rendering, which it cannot catch for itself) -- modeling exactly what an error boundary does and does not catch.
Checks: correctly identifies render errors as caught · correctly identifies event-handler errors as not caught · correctly identifies async errors as not caught
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Stuck? Get a hint.
Independent exercise
Independent exercise
Write identifyExtractableResponsibilities(componentDescription) where componentDescription is an object like { manages: ['form state', 'fetch logic', 'modal visibility'] }. Return the array of responsibilities EXCLUDING the first one (assume the first is the component's own core purpose, and the rest are candidates for extraction into separate components/hooks).
Checks: correctly identifies extractable responsibilities · returns empty for a single-responsibility component
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Stuck? Get a hint.
Guided local lab
Refactor an Oversized Component Locally
Runs on your computerYou've inherited a single 'Dashboard' component that has grown to handle course fetching, search filtering, and error display all in one place. Refactor it into a maintainable structure using composition, a custom hook, and an error boundary — all techniques from this course.
Required tools
- Node.js (20.x LTS or newer)
- npm (10.x (bundled with Node.js))
Setup
- Reuse the Vite + React project from an earlier lesson's lab, or run `npm create vite@latest dashboard-refactor -- --template react` for a fresh one.
- Replace `src/App.jsx` with the oversized starter file below.
- Run `npm run dev` and confirm the dashboard renders and searches correctly before refactoring anything.
Project structure
dashboard-refactor/
src/
App.jsx (oversized, to be split apart)
ErrorBoundary.jsx (new)
useCourseSearch.js (new, extracted hook)
CourseList.jsx (new, extracted component)
main.jsxStarter files
src/App.jsx
import { useEffect, useState } from "react";
const ALL_COURSES = [
{ id: 1, title: "HTML & CSS Fundamentals" },
{ id: 2, title: "JavaScript Fundamentals" },
{ id: 3, title: "TypeScript Foundations" },
];
// Everything lives in one component: fetching, filtering, error handling,
// AND rendering -- a realistic "grew too large over time" starting point.
export default function App() {
const [query, setQuery] = useState("");
const [items, setItems] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let ignore = false;
setIsLoading(true);
setTimeout(() => {
if (!ignore) {
setItems(ALL_COURSES);
setIsLoading(false);
}
}, 300);
return () => { ignore = true; };
}, []);
const filtered = items.filter((c) => c.title.toLowerCase().includes(query.toLowerCase()));
if (isLoading) return <p>Loading...</p>;
if (error) return <p role="alert">Something went wrong.</p>;
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} aria-label="Search courses" />
<ul>
{filtered.map((c) => (
<li key={c.id}>{c.title}</li>
))}
</ul>
</div>
);
}Requirements
- Fetch-and-filter logic is extracted into a custom hook (e.g. useCourseSearch), not left inline in the top-level component
- The list-rendering markup is extracted into its own component (e.g. CourseList), accepting the filtered items as a prop
- An ErrorBoundary class component wraps the dashboard, with a fallback UI distinct from the existing loading/error states
- The refactored App.jsx is substantially shorter and reads as an assembly of pieces, not a single block handling every concern
Commands to run
Start the dev server after each refactor step to confirm nothing broke
npm run dev
Expected behavior
After refactoring, the app behaves identically from the user's perspective (search still filters correctly, loading still shows briefly) — the refactor changes internal structure only, never behavior.
Verify it yourself
Load the app and confirm the course list appears after the brief loading stateExpected: Behavior matches the pre-refactor version exactly
Type a search query and confirm filtering still worksExpected: The list narrows to matching courses, same as before the refactor
Temporarily throw an error inside CourseList's render (e.g. `throw new Error('test')`) to confirm the ErrorBoundary catches itExpected: The ErrorBoundary's fallback UI appears instead of a blank white screen or an unhandled crash; remove the test throw afterward
Troubleshooting
- The ErrorBoundary doesn't catch a thrown error — Confirm the error is thrown during RENDER (not inside a useEffect or an event handler) — error boundaries only catch rendering errors in their child tree, by design.
- After extracting useCourseSearch, the search input stops updating — Confirm the hook returns both the current query value AND a setter function, and that App.jsx's input is still wired to both.
Stuck? Get a hint.
Extension challenge
Add a componentDidCatch method to ErrorBoundary that logs the error to the console with additional context, and add a 'Try again' button to its fallback UI that resets hasError back to false.
When you've verified this locally, use the "Mark lesson complete" button below to record your progress.
Common mistakes
- Memoizing every value in a component 'just in case,' adding bookkeeping overhead that can exceed the cost of the cheap computation it was protecting.
- Expecting an error boundary to catch an error thrown inside an event handler or an async callback, when it only catches errors during rendering.
- Letting a component keep growing indefinitely instead of recognizing independent responsibilities and extracting them into separate components or hooks.
Knowledge check
Takeaway
Memoization and error boundaries are both narrow, deliberate tools — memoize a measured bottleneck, not everything; an error boundary catches only rendering errors, not events or async code — and refactoring an oversized component is component-thinking applied to existing code.
Summary
This lesson covered the real trade-offs of memoization and the precise scope of error boundaries, then applied composition, custom hooks, and an error boundary together to refactor a genuinely oversized component in a real local project.
References
Your notes
Notes save automatically.
Finished this lesson?
Mark it complete to track your progress and schedule a future review.
AI tutor
The optional AI tutor isn't enabled in this deployment. All lessons, exercises, quizzes, and search work fully without it.