Jun 01 2025
Debugging is one of the most important skills you develop as a React developer. It’s something that separates a smooth developer experience (and happier users) from hours of confusion staring at misbehaving UI. Over dozens of shipped projects, I’ve developed a set of go-to strategies—ranging from print debugging to fancy profiling and tool-assisted time-travel—that help me track down and squash bugs efficiently. In this post, I’ll walk through how I actually debug my React apps, from simple techniques to advanced workflows.
Nothing beats good old console.log()
for first-line debugging. Logging is especially useful in React because it helps you observe how values change on each render, in event handlers, or even outside your component tree (i.e. right inside your module imports).
// Inside a component function
function MyComponent({ user }) {
console.log("Renders with user:", user);
// ...
}
When you log inside a component, it will print every time the component renders. This is great for tracking state changes or prop updates.
// Inside an event handler
function handleClick() {
console.log("Button clicked");
// Handle click logic
}
Event handlers are another common place to log. This helps you see when actions occur and what data is being passed around.
// Outside a component
import { useEffect } from "react";
console.log("App loaded");
function App() {
// App code
}
Logging outside a component (e.g., at the module level) is useful for one-time initialization messages or debugging imports.
“Trick” I like to use is when I have a lot of values to log in browser console I do:
console.log("User data:", {
userId: user.id,
userName: user.name,
userEmail: user.email,
});
This way, I can expand the logged object in the console to see all properties at once, rather than logging each one separately. And I can easily find it with “User data:” string.
For better severity separation, use:
console.warn()
for things that aren’t fatal but might indicate a problemconsole.error()
for actual error statesThe console API is richer than many realize:
console.table(data)
Great for displaying arrays of objects as a table.
console.group()
and console.groupEnd()
Group related logs for easier scanning:
console.group("User Debug Info");
console.log(user);
console.log(address);
console.groupEnd();
Web browser output, group collapsed and expanded.
console.time()
and console.timeEnd()
Quick performance timings:
console.time("API fetch");
await fetchData();
console.timeEnd("API fetch");
Web browser output, time taken to fetch data.
When logging gets frequent or noisy, it’s time to start thinking about a custom logger…
Hard-coding logs everywhere gets messy, especially when moving between dev, QA, and prod environments. I like to wrap my logs in a simple env-aware logger that only prints when it makes sense:
const logger = {
debug: (message, ...args) => {
if (["local", "dev", "qa"].includes(process.env.NODE_ENV)) {
console.log(`[DEBUG] ${message}`, ...args);
}
},
warn: (message, ...args) => {
if (["local", "dev", "qa"].includes(process.env.NODE_ENV)) {
console.warn(`[WARNING] ${message}`, ...args);
}
},
error: (message, ...args) => {
if (["local", "dev", "qa"].includes(process.env.NODE_ENV)) {
console.error(`[ERROR] ${message}`, ...args);
}
},
};
// Usage
logger.debug("User logged in", { userId: 123, timestamp: Date.now() });
logger.warn("API response slow", { endpoint: "/users", responseTime: 2000 });
logger.error("Failed to fetch data", new Error("Network error"));
This helps keep the production console clean, while you still get all the info you need in dev. Besides it give you a simple way of finding logs in the console, since they all start with [DEBUG]
, [WARNING]
, or [ERROR]
.
The browser is your React app’s runtime environment, and its DevTools are some of the most powerful debugging resources. I will describe briefly few features that I use most often.
:hover
, :focus
), and experiment with changes before moving to source.
Inspect API requests/responses in detail.
Performance debugging: Track time-to-first-byte, how long requests are taking, or find missing/broken calls.
Response overrides:
Modify API responses on the fly:
This is invaluable for testing error handling and edge-case flows.
React DevTools is a must-have for any React workflow. It gives you superpowers missing in vanilla browser tools.
React.memo
, useCallback
, useMemo
, or context refactoring.React 19+ tools continue improving. Make sure your devtools extension is up-to-date for access to the latest features.
Many React projects run in VS Code, and its debugging support is impressive once you configure it.
By setting up a launch.json
you can use VS Code’s built-in debugger:
{
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Debug React App",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}/src"
}
]
}
This allows you to:
Pro tip: Use conditional breakpoints (right-click, “Edit breakpoint…”) to pause only for certain variable values—awesome for hunting one-off behavior in loops/lists.
React’s error boundaries are your last line of defense against unhandled exceptions blowing up your entire app. They let you catch errors in child components, log them, and provide a fallback UI:
import { Component, ErrorInfo, ReactNode } from "react";
interface ErrorBoundaryProps {
fallback: ReactNode;
children: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(_: Error): ErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("Error caught by boundary:", error, errorInfo);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
// Usage
const App = () => (
<ErrorBoundary fallback={<h1>Something went wrong</h1>}>
<MyComponent />
</ErrorBoundary>
);
From React 19 onwards, error boundaries have improved their integration with modern Suspense/data loading flows. Still, I recommend a global error boundary at the top of your app—and local ones for error-prone UI islands.
For fine-grained performance tuning (beyond logging), use the <Profiler>
component to measure render timings for specific component trees:
import React, { Profiler } from "react";
function onRenderCallback(
id, // the "id" prop of the Profiler tree that has just committed
phase, // either "mount" or "update"
actualDuration, // time spent rendering the committed update
baseDuration,
startTime,
commitTime,
) {
console.log(`${id} [${phase}] render: ${actualDuration}ms`);
}
<Profiler id="MyList" onRender={onRenderCallback}>
<MyList />
</Profiler>;
If your app feels slow, incrementally “profile” individual components until you find and optimize the bottleneck.
Sometimes the best debugging is preventing bugs in the first place. Writing unit and integration tests helps catch errors early and provides a quick, repeatable way to validate fixes.
import { render, screen } from "@testing-library/react";
import MyComponent from "./MyComponent";
test("renders correct content", () => {
render(<MyComponent />);
const element = screen.getByText(/expected text/i);
expect(element).toBeInTheDocument();
});
New bug surfaced? Add a test to cover it before attempting a fix. Not only does this help avert regressions, but it shortens future debugging effort.
If you use Redux (including Redux Toolkit), Redux DevTools Extension is a must install.
Mastering React debugging is a journey—not just a list of tools, but an approach: start simple, work your way up. Use logs to spot quick issues, DevTools to understand structure and data flow, profilers for optimizing performance, error boundaries for resilience, and tests for prevention.
Every app is different; the best debuggers tailor their workflow—but these tips will cover the vast majority of bugs you’ll encounter. Happy debugging!
Got your own favorite debugging tricks? Share them below! 🚀