⚛ The Course  /  Module 9 · Practice & Debugging · 9.2
Module 9 · Practice & Debugging

9.2 A debugging playbook

Your app is slow, or state is stale, or the console is full of hydration warnings — and you have no idea where to start poking. This lesson turns everything you have learned about React's engine into a repeatable routine: a short, ordered checklist that takes you from a vague symptom to the exact line to fix.

Step 0 · Wire up your instruments first

Before you change a single line, get visibility. You cannot fix a slow render you cannot measure.

React ships a built-in stopwatch: the <Profiler> component. Wrap any subtree and it hands you exact per-render timings in a callback — no browser extension required.

The real code

import { Profiler } from 'react';

<Profiler id="App" onRender={onRenderCallback}>
  <App />
</Profiler>

function onRenderCallback(
  id,              // "App"
  phase,           // "mount" or "update"
  actualDuration,  // Time spent rendering
  baseDuration,    // Estimated time without memoization
  startTime,
  commitTime,
  interactions     // Set of interactions (not lane-related)
) {
  console.log(`${id} took ${actualDuration}ms to render`);
}

Read three numbers from it: a long actualDuration means slow renders; frequent calls mean unnecessary re-renders; a large baseDuration means a heavy tree that memoization would help.

For deeper poking, you can drop a tiny toolkit onto window in development and inspect Fiber internals straight from the console — lanes as binary, the Fiber tree, props and state on any node.

Show the full ReactDebug console toolkit
if (process.env.NODE_ENV === 'development') {
  window.ReactDebug = {
    // Get fiber root — works for both legacy render and createRoot apps
    getRoot() {
      return document.querySelector('#root')._reactRootContainer?._internalRoot
        || document.querySelector('#root').__reactContainer$;
    },

    // Print lanes as binary strings
    lanes() {
      const root = this.getRoot();
      if (!root) return console.log('No root found');

      console.group('Lane Status');
      console.log('Pending:',   root.pendingLanes.toString(2).padStart(32, '0'));
      console.log('Suspended:', root.suspendedLanes.toString(2).padStart(32, '0'));
      console.log('Pinged:',    root.pingedLanes.toString(2).padStart(32, '0'));
      console.groupEnd();
    },

    // Find fiber by component name (depth-first)
    findFiber(name, fiber = this.getRoot()?.current) {
      if (!fiber) return null;
      if (fiber.type?.name === name || fiber.type?.displayName === name) {
        return fiber;
      }
      let result = this.findFiber(name, fiber.child);
      if (result) return result;
      return this.findFiber(name, fiber.sibling);
    },

    // Detailed fiber inspection
    inspectFiber(nameOrFiber) {
      const fiber = typeof nameOrFiber === 'string'
        ? this.findFiber(nameOrFiber)
        : nameOrFiber;

      if (!fiber) return console.log('Fiber not found');

      console.group(`Fiber: ${fiber.type?.name || fiber.type || 'Unknown'}`);
      console.log('Tag:',   fiber.tag);
      console.log('Lanes:', fiber.lanes.toString(2));
      console.log('Flags:', fiber.flags.toString(2));
      console.log('Props:', fiber.memoizedProps);
      console.log('State:', fiber.memoizedState);
      console.groupEnd();
    },

    // Print the component tree with lane annotations
    tree(fiber = this.getRoot()?.current, indent = 0) {
      if (!fiber) return;
      const name  = fiber.type?.name || fiber.type || `[${fiber.tag}]`;
      const lanes = fiber.lanes ? ` (lanes: ${fiber.lanes})` : '';
      console.log('  '.repeat(indent) + name + lanes);
      this.tree(fiber.child, indent + 1);
      this.tree(fiber.sibling, indent);
    },

    // Log every createElement call
    trackRenders() {
      const original = React.createElement;
      React.createElement = function(type, ...args) {
        if (typeof type === 'function') {
          console.log(`Creating: ${type.name || 'Anonymous'}`);
        }
        return original.call(this, type, ...args);
      };
      console.log('Render tracking enabled');
    }
  };

  console.log('ReactDebug utilities loaded. Try: ReactDebug.lanes(), ReactDebug.tree()');
}

Step 1 · Name the symptom (the routing table)

This is the heart of the playbook. Do not start fixing — start classifying. Find your symptom in the left column, run the steps on the right, and only then jump to the matching section below.

Symptom Steps to diagnose
Unexpected re-renders Check for inline objects / functions in props (new reference every render)
Check context value stability
Add React.memo where needed
Profile with React DevTools Profiler
Slow renders Profile with React DevTools — look at actualDuration
Check for expensive computations (wrap with useMemo)
Virtualize long lists (>200 items)
Consider startTransition for heavy non-urgent updates
Stale state / stale closures Audit useEffect / useCallback dependency arrays
Use functional updater: setState(prev => ...)
Verify cleanup functions are returned from every useEffect
Hydration errors Check for browser-only APIs (window, document) used during render
Verify server and client receive the same data
Use suppressHydrationWarning sparingly and only where unavoidable
Suspense issues Verify the data source actually throws a Promise (not just returns null)
Check Suspense boundary placement
Test that the fallback renders in isolation
Memory leaks Return a cleanup function from every useEffect that subscribes or sets a timer
Cancel async operations on unmount with a cancelled flag
Clear all intervals and timeouts in the cleanup

Step 2 · Slow or too many renders

The single most common performance surprise comes from one wrong belief.

So if a screen is sluggish, count what you are actually asking React to build. A list of 100 rows is 100 Fibers and 100 DOM nodes, even when only 20 are on screen.

┌─────────────────────────────────────────────────────────┐
│  Your Component: <ProductList items={100} />            │
├─────────────────────────────────────────────────────────┤
│  What React Does:                                       │
│                                                         │
│  Fiber nodes created:     100                           │
│  Components rendered:     100                           │
│  DOM nodes created:       100                           │
│  Items visible:            20                           │
│                                                         │
│  Wasted work:              80 items (80%!)              │
└─────────────────────────────────────────────────────────┘

The fix is virtualization: only hand React the items that are near the viewport. Libraries like react-window do exactly this — they swap a flat list of 100 for a windowed list of ~14.

Show the react-window before / after
// Without virtualization: 100 DOM nodes
function ProductList({ items }) {
  return (
    <div className="list">
      {items.map(item => (           // 100 iterations
        <ProductCard key={item.id} {...item} />
      ))}
    </div>
  );
}

// With virtualization: ~14 DOM nodes
import { FixedSizeList } from 'react-window';

function ProductList({ items }) {
  return (
    <FixedSizeList
      height={600}
      itemCount={items.length}
      itemSize={50}
    >
      {({ index, style }) => (       // Only ~14 calls!
        <ProductCard style={style} {...items[index]} />
      )}
    </FixedSizeList>
  );
}

Use this rough rule of thumb to decide whether it is worth the trouble:

ScenarioRecommendation
< 50 itemsUsually not needed
50–200 itemsConsider if performance issues appear
> 200 itemsStrongly recommended
Infinite scrollRequired
Complex item componentsLower threshold — consider at 20+

Step 3 · Stale state and effects that fire wrong

"Stale state" is rarely a React bug — it is almost always a misunderstanding of when things run. Three patterns cover most cases.

State updates are scheduled, not instant. Reading the variable right after setState gives you the old value.

// State update is scheduled, not synchronous
function handleClick() {
  setCount(count + 1);
  console.log(count);  // Still the OLD value!
}

// Correct: use functional updater if you need the new value
function handleClick() {
  setCount(prev => {
    console.log('New value will be:', prev + 1);
    return prev + 1;
  });
}

Heavy work in a handler blocks the UI. Split urgent from non-urgent with startTransition so typing stays responsive while the expensive update is interruptible.

// Everything high priority — filter blocks the UI
function handleInput(e) {
  setQuery(e.target.value);
  setResults(filter(data));  // Heavy work blocks the input!
}

// Correct: defer the heavy work
function handleInput(e) {
  setQuery(e.target.value);        // High priority — UI stays fast
  startTransition(() => {
    setResults(filter(data));      // Low priority — can be interrupted
  });
}

Side effects in render fire more than once. In Concurrent Mode React may call your render function several times before committing, and Strict Mode double-invokes renders in development on purpose to surface this. So anything with a side effect belongs in useEffect, not in the render body.

// Side effects in render fire multiple times — never do this
function Component() {
  console.log('Rendering');  // Called multiple times!
  fetch('/api/log');         // Called multiple times!
  return <div>Content</div>;
}

// Move side effects to useEffect
function Component() {
  useEffect(() => {
    console.log('Rendered');  // Called once after commit
    fetch('/api/log');
  }, []);
  return <div>Content</div>;
}
One more trap — useLayoutEffect for async work
// Blocks every render — only for DOM measurement/mutation
useLayoutEffect(() => {
  fetchData().then(setData);  // Async work; doesn't need layout!
});

// Correct: async work belongs in useEffect
useEffect(() => {
  fetchData().then(setData);
}, []);

Step 4 · Hydration mismatches

Hydration mismatches happen when the HTML the server sent does not match what the client renders. React tries to reuse the server's DOM nodes; any mismatch forces it to throw them away and re-render — slow, and a console warning. Almost every cause reduces to "the server and client computed different output."

The classic offender is a browser-only API read during render — the server has no window.

// window doesn't exist on the server!
function Component() {
  const width = window.innerWidth;
  return <div style={{ width }}>{width}px</div>;
}

// Fix: initialise to a safe default; read window in useEffect
function Component() {
  const [width, setWidth] = useState(0);
  useEffect(() => {
    setWidth(window.innerWidth);
  }, []);
  return <div style={{ width }}>{width}px</div>;
}

The same shape causes the same bug with anything non-deterministic — new Date() renders at a different moment on each side, and Math.random() simply differs. For IDs, reach for the useId hook, which produces the same value on server and client.

// Math.random() produces different values on server and client
function Component() {
  return <div id={`item-${Math.random()}`}>Content</div>;
}

// Fix: use the useId hook for stable server/client IDs (React 18+)
function Component() {
  const id = useId();
  return <div id={id}>Content</div>;
}

Step 5 · A Suspense fallback that never shows

If your loading fallback never appears even though data is clearly being fetched, this is almost always why: the fetch lives in an effect, and return null is doing the "loading" job. The boundary above it is never notified. Switch to a data source that actually suspends.

// Problem: fallback never shows
function DataComponent() {
  const [data, setData] = useState(null);
  useEffect(() => {
    fetch('/api/data').then(r => r.json()).then(setData);
  }, []);

  if (!data) return null;  // Does NOT trigger Suspense!
  return <div>{data.name}</div>;
}

// Fix — use() hook (React 19+): throws the promise automatically
function DataComponent() {
  const data = use(fetchData());  // Throws promise, triggers Suspense
  return <div>{data.name}</div>;
}

// Fix — Suspense-enabled library (React Query, SWR, etc.)
function DataComponent() {
  const { data } = useSuspenseQuery(['data'], fetchData);
  return <div>{data.name}</div>;
}

Step 6 · Memory leaks

The fix is always the same shape: return a cleanup function. A subscription is the textbook case.

// Subscription runs forever after unmount
function Component() {
  useEffect(() => {
    const subscription = eventEmitter.subscribe(handler);
    // No cleanup!
  }, []);
}

// Fix: always return a cleanup function
function Component() {
  useEffect(() => {
    const subscription = eventEmitter.subscribe(handler);
    return () => subscription.unsubscribe();  // Cleanup on unmount
  }, []);
}

The other two sources are the same idea wearing different clothes: an interval that is never cleared, and an async call that resolves after the component is gone and tries to setState on a corpse.

Show the async cancelled-flag pattern
// Fix: guard with a cancelled flag
function Component() {
  const [data, setData] = useState(null);

  useEffect(() => {
    let cancelled = false;

    fetchData().then(result => {
      if (!cancelled) {
        setData(result);
      }
    });

    return () => { cancelled = true; };
  }, []);
}

Step 7 · Decode the error message

Last stop: the console gave you an actual message. Each of React's common errors maps to one root cause and one standard fix.

Error Message Root Cause Fix
Cannot update a component while rendering a different component Calling setState of a parent during a child's render phase Move the call into a useEffect so it runs after the render phase
Maximum update depth exceeded useEffect with no dependency array calls setState, which triggers a re-render, which triggers the effect again — infinite loop Add a proper dependency array; use [] to run once on mount
Objects are not valid as a React child Rendering a plain object ({`{user}`}) directly in JSX instead of a string or number Render user.name or JSON.stringify(user)
Each child in a list should have a unique "key" prop Array mapped without a key, or using array index as key in a dynamic list Use a stable unique ID from the data: key={item.id}

The real code

Why trust "React has no viewport awareness" from Step 2? Because it is visible in React's own reconciler. When children are an array, React walks every entry — first to validate keys, then to create a Fiber for each one. There is no isInViewport() guard anywhere in the loop.

function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {
  // Iterates ALL children for key validation
  for (var i = 0; i < newChildren.length; i++) {
    var child = newChildren[i];
    knownKeys = warnOnInvalidKey(child, knownKeys, returnFiber);
  }

  // Creates a Fiber for EVERY child — no viewport check anywhere
  for (; newIdx < newChildren.length; newIdx++) {
    var _newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
    // ↑ Called for ALL 100 items, not just the 20 that are visible

    lastPlacedIndex = placeChild(_newFiber, lastPlacedIndex, newIdx);
  }

  return resultingFirstChild;
}

And every Fiber that loop creates — all 100 of them — then passes through beginWork, which renders the component or creates the DOM node:

function beginWork(current, workInProgress, renderLanes) {
  switch (workInProgress.tag) {
    case FunctionComponent:
      return updateFunctionComponent(...); // Called for EACH of 100 items
    case HostComponent:
      return updateHostComponent(...);     // DOM nodes created for ALL
  }
}

That is the whole argument: React's philosophy is to render exactly what you tell it to render, and leave viewport optimization to specialized libraries (react-window, react-virtualized, Intersection Observer, or pagination). The engine you studied all course long does no magic skipping — which is precisely why the playbook works.