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 neededProfile with React DevTools Profiler |
| Slow renders |
Profile with React DevTools — look at actualDurationCheck 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 arraysUse functional updater: setState(prev => ...)Verify cleanup functions are returned from every useEffect
|
| Hydration errors |
Check for browser-only APIs (window, document) used during renderVerify 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 timerCancel async operations on unmount with a cancelled flagClear 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:
| Scenario | Recommendation |
|---|---|
| < 50 items | Usually not needed |
| 50–200 items | Consider if performance issues appear |
| > 200 items | Strongly recommended |
| Infinite scroll | Required |
| Complex item components | Lower 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.