Introduction

Let’s be honest — debugging React apps isn’t always straightforward. You build a shiny component, everything seems fine, and then… boom! The app gets sluggish, your console throws cryptic errors, or worse — something just silently breaks. And of course, it only happens when you show it to someone.

React is awesome. It’s declarative, powerful, and flexible. But with that power comes complexity — especially as your application grows. Components rerender for mysterious reasons. State behaves like it’s got a mind of its own. Hooks go rogue. Async code? It’s basically a jungle.

So, why does effective debugging matter so much in modern React development?

Because bugs are inevitable — but bad user experiences don’t have to be.

In modern apps, performance is a feature. Users expect snappy interactions, flawless navigation, and zero crashes. And if we don’t debug properly, we risk shipping something that works… until it doesn’t. That’s why getting good at debugging React isn’t just nice to have — it’s essential.

In this article, we’ll break down the most common issues developers face when working with React:

  • unnecessary re-renders (hello laggy UI)
  • memory leaks (your app eats RAM like candy)
  • hook misbehavior (what even is this error?)
  • async bugs (why does this data update twice?)

But don’t worry — we’re not just here to point out problems. We’ll walk through how React really works under the hood, and how to use tools like React DevTools, why-did-you-render, and logging services to track down issues before your users do.

Grab a coffee and let’s dive into the world of debugging. Your React apps are about to become cleaner, faster, and more reliable than ever.

Debugging re-renders

Ah, re-renders. Sometimes they feel like React’s passive-aggressive way of telling you: “You did something… but I won’t say what.”

Let’s start by understanding how React re-renders work.

React uses something called the reconciliation algorithm to update the UI. It compares the previous virtual DOM with the new one, and only applies the minimal set of changes to the actual DOM. Sounds efficient, right? It is — until components start re-rendering when they really don’t need to.

Imagine a button re-rendering every time the parent updates, even though its props never change. Multiply that by 50 components, and your app starts to feel like it’s running on a potato.

So how do we avoid this?

Use React.memo for functional components

If a component receives the same props and always renders the same output — wrap it in React.memo(). It’s like telling React: “Hey, don’t touch this unless something really changed.”

import React from 'react';
import { FC, memo } from 'react';

type ButtonProps = {
  label: string;
}

export const Button: FC<ButtonProps> = memo(({label}) => {
  console.log('Button rendered');

  return <button>{label}</button>;
})
useMemo and useCallback (but wisely)

These hooks help avoid recalculating values or re-creating functions on every render.

  • useMemo caches the result of expensive computations
  • useCallback caches a function reference, so child components don’t re-render unnecessarily
import React from 'react';
import { FC, useMemo, useCallback, useState } from 'react';

export const ExpensiveComponent: FC = () => {
  const [count, setCount] = useState(0);

  const expensiveValue = useMemo(() => count * 2, [count]);
  const handleClick = useCallback(() => setCount(prev => prev + 1), []);

  return (
    <div>
      <p>{expensiveValue}</p>
      <button onClick={handleClick}>Increment</button>
   </div>
  );
};

But here’s the deal: don’t overuse them. They’re not magic performance boosts — they have their own costs, like added memory usage and the overhead of maintaining the cache. Use them when you actually face performance issues or see repeated re-renders in profiling tools.

Use why-did-you-render

One of the best dev tools for spotting unnecessary re-renders. This library tells you exactly which components re-rendered and why — and often it’s eye-opening.

import React from 'react';
import whyDidYouRender from '@welldone-software/why-did-you-render';

if (process.env.NODE_ENV === 'development') {
  whyDidYouRender(React);
}

Add it in development mode, and it’ll log detailed info in the console, like:

[why-did-you-render] <MyComponent> re-rendered because props.changed

Super useful when your app is mysteriously slow and you can’t figure out why.

Using React DevTools

If you’re not using React DevTools yet, you’re basically debugging React with your eyes closed. This tool is your window into the component world — like X-ray vision for your app.

Let’s break it down.

Components tab: your React map

The components tab shows the full component tree. Click around and you’ll see props and state in real-time. You can even edit values on the fly — it’s like playing God with your app.

Want to know why a component behaves a certain way? Just look at the props/state it currently has. That’s your first clue.

ℹ️ Pro tip: you can right-click a component and select “Store as global variable.” This gives you direct access to it from the browser console as $r.

$r.setState({open: true})

Boom — instant state change for testing.

Highlight updates

This is gold when debugging re-renders. In the DevTools settings (gear icon), turn on:

Highlight updates when components render.

Now every time a component re-renders, it flashes briefly. If your footer is blinking when you type in a search bar — that’s not okay. Time to investigate.

Profiler: the performance detective

Switch to the Profiler tab, hit record, and interact with your app. You’ll get a flame graph showing which components were rendered, how long they took, and why they were rendered.

You’ll see things like:

  • props changed
  • context changed
  • hook value changed

This is your best friend for finding bottlenecks and wasted renders.

ℹ️ Bonus tip: profiler even suggests you wrap some components in React.memo() when it sees repetitive renders.

Debugging hooks

Hooks brought power and elegance to React — but also a new class of bugs. Ever had a hook fire twice when you expected it once? Or a useEffect do something weird on unmount? Yeah, we’ve all been there.

Let’s unpack the most common hook headaches and how to debug them like a pro.

useDebugValue() – your сustom рook’s сonsole.log

If you’ve built a custom hook and have no idea what it’s doing internally — try useDebugValue.

import React from 'react';
import { useDebugValue, useState, useEffect } from 'react';

export const useAuthStatus = (): boolean => {
  const [loggedIn, setLoggedIn] = useState(false);

  useEffect(() => {
  const timeout = setTimeout(() => setLoggedIn(true), 1000);

  return () => clearTimeOut(timeout);
}, []);

  useDebugValue(loggedIn ? 'Logged In' : 'Logged Out');

  return loggedIn;
}

Now in React DevTools, next to the hook name, you’ll see the label “Logged In” or “Logged Out”. It’s a clean way to surface hook internals without dumping them into the console.

It won’t show up in production, so it’s a safe dev-only tool.

useEffect: the async troublemaker

Let’s face it: useEffect is powerful but easy to mess up. Here’s what you need to watch out for:

  1. missing dependency array – causes effects to run more than expected.
  2. stale closures – when you’re using old state or props inside an async callback.
  3. race conditions – especially in fetches or timeouts.

Want to cancel a fetch if the component unmounts? Use AbortController:

import { useEffect, useState } from 'react';

const useUserData = (): unknown => {
 const [data, setData] = useState<unknown>(null);

 useEffect(() => {
  const controller = new AbortController();
  const fetchData = async (): Promise<void> => {
    try {
     const res = await fetch('/api/user', { signal: controller.signal     });
     const result = await res.json();

     setData(result);
    } catch (error: unknown) {
     if ((error as Error).name !== 'AbortError') {
      console.error(error);
     }
    }
 }

 fetchData();

 return () => controller.abort();
 }, []);

 return data;
};

This avoids a very common bug: trying to update state on an unmounted component.

Structure hooks like lego blocks

Debugging is way easier when your hooks are modular and traceable. Instead of writing one massive useEffect, split it:

useEffect(() => {
  //subscribe to service
}, []);

useEffect(() => {
  //update on prop change
}, [prop]);

useEffect(() => {
  //cleanup or side-effect 
}, [state]);

This makes it easier to track what does what, especially during hot reloads or rapid updates.

Error boundaries

React is pretty forgiving… until it’s not. One broken component can take down your entire UI. That’s where error boundaries come in — your app’s safety net when things go sideways.

What are error boundaries?

Think of error boundaries as React’s version of try...catch — but for components. They catch render-time and lifecycle errors, so your app doesn’t crash entirely when one part fails.

Here’s the basic setup:

import React from 'react';
import { Component, ErrorInfo, PropsWithChildren } from 'react';
type ErrorBoundaryState = {
  hasError: boolean;
};

class ErrorBoundary extends Component<PropsWithChildren, ErrorBoundaryState> {
  state: ErrorBoundaryState = { hasError: false };

  static getDerivedStateFromError(): ErrorBoundaryState {
    return { hasError: true };
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
    console.error(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return <h2>Something went wrong.</h2>;
    }

  return this.props.children;
 }
}

export default ErrorBoundary;

Then wrap vulnerable parts of your app like this:

<ErrorBoundary>
  <UserProfile />
</ErrorBoundary>

Boom — if UserProfile crashes, users won’t see a blank screen. Instead, they’ll get a friendly message.

Where should you use them?

Not everywhere. Wrapping your entire app is a good fallback, but the real power comes from using scoped boundaries:

  • around remote data components (API failure? Show fallback).
  • around feature experiments (A/B test gone wrong? Don’t break the site).
  • around custom rendering logic (complex charts, dynamic UIs, etc).
Logging errors with external tools

Capturing the error is only half the job — now let’s send it somewhere useful.

In your componentDidCatch, or inside getDerivedStateFromError, forward the error to logging tools like SentryLogRocket, or Replay.io:

componentDidCatch(error, info) {
  logErrorToService(error, info);
}

Integrating with logging tools

Catching errors locally is good. But knowing what actually happened in production? That’s next-level debugging. Integrating logging tools gives you visibility into the bugs your users are seeing — and the context around them.

Sentry: your frontend black box

Sentry is like a crash reporter with superpowers. It tracks exceptions, shows stack traces, and connects them to the actual lines of code that broke.

How to add Sentry in a React app:

npm install @sentry/react @sentry/tracing

Then configure it once at the root of your app:

import as Sentry from "@sentry/react";
import { BrowserTracing } from "@sentry/tracing";

Sentry.init({
 dsn: "https://your-dsn-url",
 integrations: [new BrowserTracing()],
 tracesSampleRate: 1.0,
});

Want to wrap an error boundary with Sentry’s own handler?

const SentryBoundary = Sentry.withErrorBoundary(MyComponent, {
  fallback: <p>Oops, something went wrong.</p>,
});
LogRocket / Replay.io: rewind the bug

Sometimes logs aren’t enough. You want to see what the user did — clicks, scrolls, form entries — all leading up to the crash.

That’s where tools like LogRocket and Replay.io shine. They record sessions as if you’re watching over the user’s shoulder — ethically and securely.

Example LogRocket integration:

npm install logrocket

Then configure it in your app:

import LogRocket from 'logrocket';

LogRocket.init('your-app-id');

You can also link LogRocket to Sentry to watch the session where the error occurred.

Debugging isn’t just about solving errors — it’s about understanding context. Tools like Sentry and LogRocket give you that full picture.

Best practices for safe logging
  • don’t log sensitive data: strip emails, tokens, passwords from logs.
  • throttle logs in production: avoid log floods that can hide real issues.
  • use environment filters: don’t record in development or staging unless necessary.
  • tag and group errors: use metadata (like user ID, app version) for faster triage.

Debugging asynchronous code

Asynchronous code is tricky. You click a button, wait for a response… and boom — something goes wrong. Maybe nothing will happen. Or maybe two things happen at once. Or maybe something was canceled halfway through.

Let’s break down how to tame the async chaos in React.

Debugging fetch / axios requests

First things first — use the browser’s network tab to track API calls. It shows:

  • if the request went out
  • what the response was
  • if it failed and why

In code, wrap fetch/axios in try/catch to surface errors cleanly:

useEffect(() => {
  const fetchData = async () => {
    try {
      const res = await fetch("/api/data");
      const json = await res.json();

      setData(json);
    } catch (error) {
      console.error("API error:", error);
    }
  };

  fetchData();
}, []);
Prevent race conditions with abortController

Let’s say a user types quickly in a search input, triggering multiple fetches. Only the last one matters — the rest should be canceled.

useEffect(() => {
  const controller = new AbortController();
  const signal = controller.signal;

  fetch(`/api/search?q=${query}`, { signal })
    .then (res => res.json())
    .then(setResults)
    .catch(err => {
      if (err.name !== "AbortError") {
        console.error("Search error:", err);
      }
    });

  return () => controller.abort();
});

This avoids outdated requests from overriding newer data — a super common bug.

Testing and mocking async code

Tools like mock service worker (MSW) help simulate API behavior during development and testing. This lets you test how your app reacts to loading, errors, and delays.

Use jest with @testing-library/react to assert behavior:

test("displays loading then data", async () => {
  render(<MyComponent />);
  expect (screen.getByText(/loading/i)).toBeInTheDocument();
  expect(await screen.findByText(/data loaded/i)).toBeInTheDocument();
});

Async bugs don’t stand a chance when you can simulate real-world edge cases.

State and context debugging

We all love hooks like useStateuseReducer, and useContext. But debugging them can feel like solving a mystery without clues.

Let’s fix that.

Debugging useState and useReducer

The best first step is to log state changes:

useEffect(() => {
  console.log(‘Current state: ‘, myState);
}, [myState]);

For useReducer, add logging inside the reducer itself:

function reducer(state, action) { 
  console.log("Dispatching:", action); 
  console.log("Previous state:", state);

  const newState = { ...state, ...action.payload }; 
  console.log("New state:", newState);

  return newState;
}

That’s a quick and dirty way to trace what’s changing — and when.

Inspecting react context with DevTools

In React DevTools, select a component that consumes a context — you’ll see a special “Context” section with current values.

This is huge when debugging deeply nested providers or mismatched consumers.

Also, you can make context more debug-friendly by giving it a name:

export const ThemeContext = React.createContect('light');
ThemeContext.displayName = 'ThemeContext'; 

No more “Context.Provider” mystery blobs in DevTools.

Design state for debugging
  • group related values in one object (useState({ name, age }))
  • use useReducer when logic is complex
  • don’t over-nest — deep objects are hard to inspect and update
  • normalize large datasets (like Redux-style) for easier updates

Tests as debugging tools

Tests aren’t just about preventing regressions — they’re fantastic for debugging in disguise. When a test fails, it shows you exactly what broke, where, and why.

Unit & E2E = early warnings
  • unit tests catch logic bugs in hooks, reducers, and components.
  • end-to-end (E2E) tests (e.g. with cypress or playwright) simulate real user behavior — clicking, typing, submitting.

When something fails in a test, it’s often a sign of a logic issue you missed manually.

Debugging with @testing-library/react

Testing Library encourages tests that match what users do — so if something breaks there, it likely breaks in the real world too.

test("button submits form data", async () => {
  render(<Form />);
  userEvent.type(screen.getByLabelText("Name"), "John")
  userEvent.click(screen.getByRole("button", { name: /submit/i }));

  expect(await screen.findByText(/thank you/i)).toBeInTheDocument();
});

If this test fails, it tells you whether the button was found, whether the state updated, and whether the effect fired — it’s like a built-in debugger.

Conclusion

Debugging React apps can feel overwhelming — but with the right toolsmindset, and structure, you can turn bugs into learning moments instead of nightmares.

React debugging checklist
  • use React DevTools to inspect components, state, and re-renders
  • clean up async effects with AbortController
  • log or visualize hook behavior (especially useEffectuseReducer)
  • wrap app in ErrorBoundaries and send errors to Sentry
  • use LogRocket or Replay.io to replay user sessions
  • write tests — they double as bug catchers and explainers

Modern React gives you everything you need to debug like a pro — now you just need to use it.

By Mikita Klepanosau, Software Engineer, Klika Tech, Inc.