advanced32 min

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 4

Try 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.

Loading editor…

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.

Loading editor…

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.

Loading editor…

Stuck? Get a hint.

Guided local lab

Refactor an Oversized Component Locally

Runs on your computer
This lab runs on your own computer, in your own terminal and editor — not in your browser. VisaSparkSchools does not execute, run, or verify these commands for you. Follow the verification steps yourself to confirm your result.

You'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

  1. 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.
  2. Replace `src/App.jsx` with the oversized starter file below.
  3. 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.jsx

Starter 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 state

    Expected: Behavior matches the pre-refactor version exactly

  • Type a search query and confirm filtering still works

    Expected: 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 it

    Expected: 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 errorConfirm 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 updatingConfirm 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

Knowledge check

1. What does memoization actually trade away in exchange for avoiding recomputation?
2. Which of these does an error boundary NOT catch?
3. What is the underlying skill being applied when refactoring an oversized component into smaller pieces?

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.