DEV Community

Cover image for Debugging React: From Taking a Deep Breath to Finding the Root Cause
surajrkhonde
surajrkhonde

Posted on

Debugging React: From Taking a Deep Breath to Finding the Root Cause

Part 0: The Deep Breath β€” Why It's Not Just a Figure of Speech

πŸ‘¦ Nephew: Uncle! Production is broken. Users are seeing the wrong order totals. I've been randomly changing code for twenty minutes and it's getting worse.

πŸ‘¨β€πŸ¦³ Uncle: Stop. Close the editor. Take an actual breath.

πŸ‘¦ Nephew: Uncle, this is not the time for meditationβ€”

πŸ‘¨β€πŸ¦³ Uncle: It's exactly the time. Here's what's happening in your brain right now, biologically: panic narrows your attention. You start pattern-matching to the last bug you fixed, not the actual bug in front of you, and you start making changes hoping something sticks β€” what we call "shotgun debugging." It feels like progress because you're typing fast. It is not progress. It's how a 10-minute bug becomes a 3-hour bug, because now you've also introduced two new bugs from your random changes, and you don't even remember what you changed.

The deep breath isn't about calm for calm's sake. It's the physical action that switches you from reacting to observing. Everything we're about to do requires observation first. So β€” breathe, and answer me one question before you touch a single line of code: what, exactly, did you observe, versus what are you assuming?

πŸ‘¦ Nephew: I... assumed the total calculation function was wrong.

πŸ‘¨β€πŸ¦³ Uncle: You assumed. You didn't observe. That's assumption number one, and it's probably wrong, because if the calculation function itself was broken, it'd be broken for everyone, always, not intermittently. Let's actually look.


Part 1: Reproduce It β€” In React Terms, Not Vague Terms

πŸ‘¨β€πŸ¦³ Uncle: From the debugging handbook, step one is always "reproduce." In React, "reproduce" has a very specific, narrower meaning than in general backend debugging β€” you need to reproduce it down to which component, in which state, given which props, rendered how many times.

The questions I want answered before we open DevTools:

  • Does it happen on first load, or only after some interaction?
  • Does it happen every time you do that interaction, or only sometimes?
  • Does it happen for a specific order, or all orders?
  • Does refreshing the page fix it, or does it come back?

πŸ‘¦ Nephew: It happens... after I edit an order and come back to the list. Not on first load. Every time.

πŸ‘¨β€πŸ¦³ Uncle: Now we have something real. "After editing and coming back" β€” that's not a calculation bug, that's a data flow bug. Something about the update isn't correctly reaching the list. Already, without opening a single tool, we've eliminated half your assumptions. This is the value of a precise reproduction β€” it does half the diagnosis for you before you've written a single console.log.

The golden rule of React reproduction: always try to shrink it to the smallest possible trigger. "The whole checkout is broken" is not a reproduction. "Clicking 'Mark Paid' on any order in the list shows the old total for exactly that one order until I refresh" is a reproduction you can actually act on.


Part 2: Open React DevTools β€” Your X-Ray Machine

πŸ‘¦ Nephew: Okay, now can I open the browser console and start console.log-ing everything?

πŸ‘¨β€πŸ¦³ Uncle: That's like a doctor reaching for a scalpel before taking an X-ray. Install React DevTools first (the browser extension) β€” it gives you two tabs, Components and Profiler, and both will tell you more in thirty seconds than an hour of scattered console.logs.

The Components tab

πŸ‘¨β€πŸ¦³ Uncle: Click on your OrderRow component in the tree. On the right, you'll see its current props and state, live, updating in real time as you interact with the app.

β–Ό OrdersPage
  β–Ό OrderRow (props: { order: {...}, onMarkPaid: fn })
      order: { id: "123", amount: 450, status: "paid" }
Enter fullscreen mode Exit fullscreen mode

πŸ‘¦ Nephew: So I can just... look at what props the component actually received, instead of guessing?

πŸ‘¨β€πŸ¦³ Uncle: Exactly. Click "Mark Paid," then look again β€” did order.amount or order.status actually update in the props panel? If the props panel shows the correct new data but the screen still shows the old value, your bug is inside the component's rendering logic. If the props panel itself still shows old data, your bug is upstream β€” in your data layer, your cache, or your parent component.

πŸ‘¦ Nephew: (clicks around) The props panel shows the old amount even after I edit it!

πŸ‘¨β€πŸ¦³ Uncle: There it is β€” we just eliminated OrderRow as a suspect entirely, in under a minute, without writing a single log statement. The bug is upstream. This is what "verify each layer separately," from the original handbook, looks like in React specifically β€” DevTools lets you check each layer of the component tree like checking each floor of a building for a leak, without tearing open every wall.

The "highlight updates when components render" setting

πŸ‘¨β€πŸ¦³ Uncle: There's a settings toggle in React DevTools β€” "Highlight updates when components render." Turn it on, and every component that re-renders flashes a colored border on screen, live. This single feature answers a question that's impossible to answer just by reading code: is this component even re-rendering when I expect it to?

πŸ‘¦ Nephew: (clicks Mark Paid) ...OrderRow isn't flashing at all when I click it!

πŸ‘¨β€πŸ¦³ Uncle: Now we know two things for certain: the data isn't updating, AND the component isn't even attempting to re-render β€” which tells us this isn't a "stale render" problem, it's that the state update itself never reached this component. We haven't found the root cause yet, but we've narrowed the entire investigation from "something's wrong with orders" to "something's wrong between the mutation firing and the store notifying this specific component." That's the power of observing before guessing.


Part 3: The Profiler β€” Understanding Why Something Rendered (or Didn't)

πŸ‘¦ Nephew: DevTools has a Profiler tab too, right? What's that for versus the Components tab?

πŸ‘¨β€πŸ¦³ Uncle: Components tab answers "what does this look like right now." Profiler answers "what happened over time, and why." Click record, perform your action (edit the order), stop recording, and you'll see a flame graph β€” remember this concept from our CPU debugging conversation, same idea, but for React renders specifically.

Render #1 (initial load)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ OrdersPage                        β”‚
β”‚  β”œβ”€ OrderRow (order 1)            β”‚
β”‚  β”œβ”€ OrderRow (order 2)            β”‚
β”‚  └─ OrderRow (order 3)            β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Render #2 (after clicking "Mark Paid" on order 2)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ OrdersPage  ← re-rendered          β”‚
β”‚  β”œβ”€ OrderRow (order 1) ← skipped   β”‚   <- memo working correctly
β”‚  β”œβ”€ OrderRow (order 2) ← skipped   β”‚   <- SUSPICIOUS β€” this one should update!
β”‚  └─ OrderRow (order 3) ← skipped   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

πŸ‘¨β€πŸ¦³ Uncle: Click on the suspicious component in the flame graph, and DevTools will actually tell you why it did or didn't render β€” "props did not change," or "hook changed," or "parent re-rendered." This one feature turns "why isn't this updating" from a guessing game into a direct answer from the tool itself.

πŸ‘¦ Nephew: It says "props did not change" for OrderRow (order 2). So React itself is telling us the new order data never actually reached this component as a prop?

πŸ‘¨β€πŸ¦³ Uncle: Exactly β€” and now our suspect list has shrunk to one very specific place: whatever is responsible for handing order 2's updated data down to this component. In our architecture from Part 1, that's the RTK Query cache. Time to look there.


Part 4: The Usual Suspects β€” React-Specific Bug Categories

πŸ‘¨β€πŸ¦³ Uncle: Before we dig into your specific bug, let me give you the mental map of React bugs. Almost every "weird React bug" falls into one of these categories. Once you recognize the shape of a bug, you know exactly where to look.

Category 1: The Stale Closure

function OrderTimer({ orderId }) {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setSeconds(seconds + 1); // BUG: "seconds" here is frozen at 0 forever
    }, 1000);
    return () => clearInterval(interval);
  }, []); // empty deps β€” this effect runs ONCE, closure captures seconds=0 forever

  return <span>{seconds}s</span>;
}
Enter fullscreen mode Exit fullscreen mode

πŸ‘¨β€πŸ¦³ Uncle: This is the single most common "why isn't my state updating correctly" bug in all of React. The setInterval callback was created once, when seconds was 0, and JavaScript closures capture the variable's value at creation time β€” so every tick, it computes 0 + 1, forever, never actually incrementing past 1. The fix: use the functional update form, which always receives the truly current value regardless of the closure's age:

setSeconds(prev => prev + 1); // always gets the LATEST value, not the closure's frozen one
Enter fullscreen mode Exit fullscreen mode

πŸ‘¦ Nephew: So the "shape" of this bug is β€” state seems frozen or uses an old value, inside a callback or effect?

πŸ‘¨β€πŸ¦³ Uncle: That's the fingerprint. Whenever you see "this value seems stuck at an old value inside a timer, event listener, or async callback," suspect a stale closure first.

Category 2: The Key Prop Identity Crisis

{orders.map((order, index) => (
  <OrderRow key={index} order={order} /> // BUG: using array INDEX as key
))}
Enter fullscreen mode Exit fullscreen mode

πŸ‘¨β€πŸ¦³ Uncle: This one causes some of the strangest-looking bugs in React β€” a form input's typed text appearing in the wrong row after the list is sorted or filtered, or a component's internal state bleeding into the wrong item entirely. Why? React uses key to decide whether a component in a list is "the same one from last render" or "a completely new one." Using array index as key means if the order of the array changes but the index doesn't, React thinks the component at position 2 is still "the same" β€” even though it now represents a totally different order.

{orders.map((order) => (
  <OrderRow key={order.id} order={order} /> // FIX: stable, unique identity
))}
Enter fullscreen mode Exit fullscreen mode

πŸ‘¦ Nephew: So the fingerprint here is β€” "state or input from one row appears on a different row after sorting, filtering, or deleting an item"?

πŸ‘¨β€πŸ¦³ Uncle: Exactly that fingerprint. The moment you see state "teleporting" to the wrong list item, check the key prop before anything else.

Category 3: The useEffect Dependency Trap

useEffect(() => {
  fetchOrderDetails(orderId).then(setDetails);
}, []); // BUG: missing orderId in deps β€” never refetches when orderId changes
Enter fullscreen mode Exit fullscreen mode

πŸ‘¨β€πŸ¦³ Uncle: This effect runs once, on mount, and never again β€” so if the component is reused for a different orderId (common in a modal that gets reused for different orders instead of being fully remounted), it keeps showing the first order's details forever. The ESLint plugin eslint-plugin-react-hooks will actually warn you about this β€” turn it on, and treat every warning as a real bug report, not a suggestion.

useEffect(() => {
  fetchOrderDetails(orderId).then(setDetails);
}, [orderId]); // FIX: refetch whenever orderId actually changes
Enter fullscreen mode Exit fullscreen mode

πŸ‘¦ Nephew: And the opposite mistake β€” too many dependencies?

πŸ‘¨β€πŸ¦³ Uncle: Just as common β€” an effect that re-runs on every render because you included a dependency (usually an object or function) that gets recreated fresh every render, even though its actual content never changes. That's exactly where the useCallback/useMemo stability we discussed in Part 1 becomes not just a performance nicety but an actual correctness fix.

Category 4: The Race Condition in Async Effects

useEffect(() => {
  fetchOrderDetails(orderId).then(setDetails); // BUG: no protection against out-of-order responses
}, [orderId]);
Enter fullscreen mode Exit fullscreen mode

πŸ‘¨β€πŸ¦³ Uncle: Imagine the user rapidly clicks between Order 1, then Order 2. Two fetches fire. If Order 1's fetch happens to resolve after Order 2's (slower network for whatever reason), you end up showing Order 1's data while viewing Order 2. This is a genuine race condition, and it's invisible in normal testing because it depends on network timing.

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

  fetchOrderDetails(orderId).then((data) => {
    if (!cancelled) setDetails(data); // ignore this response if we've moved on
  });

  return () => { cancelled = true; }; // cleanup runs when orderId changes or unmounts
}, [orderId]);
Enter fullscreen mode Exit fullscreen mode

πŸ‘¦ Nephew: So the fingerprint is β€” "sometimes shows the wrong data, but only when I click quickly, and it's not consistent"?

πŸ‘¨β€πŸ¦³ Uncle: That inconsistency, that "only sometimes, only when I'm fast" quality β€” that's the signature of a race condition every time. If a bug is flaky and timing-dependent, stop looking at your logic and start looking at your async ordering. (This is exactly why RTK Query is worth its weight in gold from Part 1 β€” it handles this cancellation and request-deduplication problem for you automatically, which is one more argument for not hand-rolling useEffect + fetch.)

Category 5: Batching Surprises

function handleClick() {
  setCount(count + 1);
  setCount(count + 1); // Expecting +2, actually only +1!
}
Enter fullscreen mode Exit fullscreen mode

πŸ‘¨β€πŸ¦³ Uncle: React batches multiple setState calls within the same event handler into a single re-render, and both calls here read the same stale count from the closure β€” so you get count + 1 twice, not count + 2. Same root idea as the stale closure bug, wearing a different hat.

function handleClick() {
  setCount(prev => prev + 1);
  setCount(prev => prev + 1); // now correctly +2, each gets the true latest value
}
Enter fullscreen mode Exit fullscreen mode

Part 5: Tracing State β€” Time Travel Instead of Guesswork

πŸ‘¦ Nephew: Back to our actual bug β€” Profiler said OrderRow's props never changed after the mutation. Where do we look now?

πŸ‘¨β€πŸ¦³ Uncle: Now we go to Redux DevTools (works for RTK Query too, since it's built on Redux under the hood). This gives you something wonderful β€” every single action that's ever fired, in order, with the full state before and after it, and you can literally click backward and forward through time.

Actions logged:
  1. ordersApi/executeQuery/fulfilled     (initial orders loaded)
  2. ordersApi/executeMutation/pending    (Mark Paid clicked)
  3. ordersApi/executeMutation/fulfilled  (mutation succeeded)
  4. ??? β€” where's the invalidation/refetch action?
Enter fullscreen mode Exit fullscreen mode

πŸ‘¦ Nephew: There's no fourth action! It just... stops after "fulfilled."

πŸ‘¨β€πŸ¦³ Uncle: There's your root cause taking shape. Remember from Part 1 β€” mutations need invalidatesTags to tell RTK Query "go refetch anything tagged this way." If that fourth action (the automatic refetch) never fires, it means the invalidatesTags on your mutation and the providesTags on your query aren't actually matching. Let's check the code.

// The bug, found:
getOrders: builder.query({
  query: () => '/',
  providesTags: ['Order'],
}),
updateOrder: builder.mutation({
  query: ({ id, ...patch }) => ({ url: `/${id}`, method: 'PATCH', body: patch }),
  invalidatesTags: (result, error, { id }) => [{ type: 'Order', id }], // only invalidates the SPECIFIC id tag
  // but the list query only provides the generic 'Order' tag, never { type: 'Order', id }!
}),
Enter fullscreen mode Exit fullscreen mode

πŸ‘¨β€πŸ¦³ Uncle: Found it. The mutation invalidates { type: 'Order', id: 123 }, but the list query only ever provided the plain tag 'Order' β€” these don't match, so RTK Query correctly (from its own perspective) sees no reason to refetch the list. This is a textbook tag-mismatch bug, and it's invisible by reading either piece of code in isolation β€” you only see it by tracing the actual action log and noticing the missing refetch.

// FIX: provide BOTH the specific and the general tag,
// and invalidate BOTH, so any consumer relying on either gets refreshed
getOrders: builder.query({
  query: () => '/',
  providesTags: (result) =>
    result ? [...result.map(({ id }) => ({ type: 'Order', id })), 'Order'] : ['Order'],
}),
updateOrder: builder.mutation({
  query: ({ id, ...patch }) => ({ url: `/${id}`, method: 'PATCH', body: patch }),
  invalidatesTags: (result, error, { id }) => [{ type: 'Order', id }, 'Order'],
}),
Enter fullscreen mode Exit fullscreen mode

πŸ‘¦ Nephew: So the "deep breath, reproduce, DevTools, Profiler, action log" path took us straight to a one-line root cause, instead of me randomly rewriting the calculation function that was never broken in the first place.

πŸ‘¨β€πŸ¦³ Uncle: That's the entire lesson of today, in one sentence.


Part 6: When It's a Prod-Only Bug β€” Source Maps and Minified Stack Traces

πŸ‘¦ Nephew: What about when a bug only happens in production, and the error message is just Error at a.js:1:48291 β€” completely useless?

πŸ‘¨β€πŸ¦³ Uncle: This is where source maps save you. Your production build minifies and bundles everything into unreadable, compressed code for performance β€” but a source map is a translation file that maps every position in that ugly minified file back to your original, readable source. Vite (and most modern build tools) can generate these automatically for production builds.

// vite.config.js
export default {
  build: {
    sourcemap: true, // generates .map files alongside your production bundle
  },
};
Enter fullscreen mode Exit fullscreen mode

πŸ‘¨β€πŸ¦³ Uncle: Upload these to your error tracking tool (Sentry, from our Part 2 discussion on RUM), and that unreadable a.js:1:48291 error transforms into an actual readable stack trace β€” OrderRow.jsx, line 42, in handleMarkPaid. Without source maps, debugging production errors is like trying to find a specific word in a book after someone removed all the spaces and line breaks. Always ship source maps to your error tracking tool β€” just don't expose them publicly on your live site, since they'd reveal your original source code to anyone who looks.

Conditional breakpoints β€” smarter than console.log spam

πŸ‘¦ Nephew: I usually just sprinkle console.log everywhere and remove them later.

πŸ‘¨β€πŸ¦³ Uncle: That works for simple cases, but for a bug that only happens on, say, the 47th item in a list, or only when order.amount is negative, you'll drown in irrelevant log spam. Instead, set a conditional breakpoint directly in Chrome DevTools' Sources tab β€” right-click the line number, add a condition like order.amount < 0, and execution only pauses when that's actually true, with the entire call stack and every variable's live value available to inspect, not just the one value you remembered to log.


Part 7: The "5 Whys" β€” Making Sure You Found the Root, Not a Symptom

πŸ‘¨β€πŸ¦³ Uncle: We found the tag mismatch. But before we call this done β€” same question as the debugging handbook always insists on β€” is this the root cause, or just the nearest visible layer?

πŸ‘¦ Nephew: It seems pretty root-cause-y to me. The tags didn't match, we fixed the tags.

πŸ‘¨β€πŸ¦³ Uncle: Ask "why" one more time. Why did the tags not match in the first place?

πŸ‘¦ Nephew: ...because whoever wrote getOrders didn't know the convention for tagging both individual items and the list?

πŸ‘¨β€πŸ¦³ Uncle: Now we're at the real root β€” not "this one file had a bug," but "our team doesn't have a documented, enforced convention for how RTK Query tags should be structured across features." Fixing the one file fixes today's symptom. Writing a short internal convention doc (or better, a lint rule or a code review checklist item) fixes it so this exact bug-shape doesn't reappear in the next feature a different teammate builds next month.

πŸ‘¦ Nephew: So the "validate" and "make it easier to debug next time" steps from the original handbook apply here too?

πŸ‘¨β€πŸ¦³ Uncle: Word for word the same principle, just wearing React clothes. And speaking of validate β€” before you close this out, write the regression test.

test('marking an order as paid refreshes the orders list', async () => {
  render(<OrdersPage />, { wrapper: ReduxProvider });

  await screen.findByText(/order #123/i);
  fireEvent.click(screen.getByRole('button', { name: /mark paid/i }));

  await waitFor(() => {
    expect(screen.getByText(/paid/i)).toBeInTheDocument();
  });
});
Enter fullscreen mode Exit fullscreen mode

πŸ‘¨β€πŸ¦³ Uncle: This is literally the integration test pattern from our testing conversation β€” and notice, this exact test would have caught today's bug before it ever reached production. A bug you debug without writing the test that would have caught it is a bug you're volunteering to debug again someday.


Part 8: Uncle's Debugging Checklist for React, Pinned to the Wall

πŸ‘¨β€πŸ¦³ Uncle: Let's compress today into something you can actually use next time, at 2 AM, when you're panicking again.

  1. Breathe. Observe before you guess. Write down what you actually saw versus what you're assuming.
  2. Reproduce precisely. Not "the app is broken" β€” "this exact action, on this exact component, every time / sometimes."
  3. Components tab first. Are the props/state actually what you expect, at each layer? Find where reality diverges from expectation.
  4. Highlight renders. Is the component even attempting to re-render? This alone eliminates half of all wrong guesses.
  5. Profiler for "why." Let React tell you directly why something did or didn't render, instead of inferring it from code reading.
  6. Match the bug's shape to a known category β€” stale closure, key prop identity, effect dependency, race condition, batching β€” most "weird" React bugs are one of these five in disguise.
  7. Redux/RTK Query DevTools for state bugs. Read the actual action log. A missing expected action tells you exactly where the chain broke.
  8. Source maps + conditional breakpoints for prod bugs and rare conditions β€” stop scrolling through minified code or log spam.
  9. Ask "why" one more time than feels necessary before calling it root cause.
  10. Write the regression test that would have caught this, so it can't quietly come back.

πŸ‘¦ Nephew: This whole thing took maybe fifteen minutes once we actually did it properly. I spent twenty minutes panicking before that.

πŸ‘¨β€πŸ¦³ Uncle: And that's the real lesson of the deep breath β€” it's not slower. It only feels slower, for the first sixty seconds. Every minute after that, it's the fastest path there is.


End of chat. Now go delete those stray console.logs before you commit.

Top comments (0)