For a long time, I believed console.log was enough. And honestly, in many cases, it is. But after years of debugging React and TypeScript applications, I discovered that the debugger sometimes provides insights that console.log simply cannot offer.
In this article, I start by making the case for console.log — its simplicity, the situations where it excels (notably heisenbugs and timing issues), and the lesser-known variants of the Console API like console.table or console.time. Then, I explain how breakpoints actually work in the browser, before showing what the debugger brings to the table: full inspection of the program's state, modifying values on the fly, and analyzing the call stack. I then cover React-specific tools — React DevTools, the Profiler, Why Did You Render — and how to debug hooks that spiral into infinite loops. Finally, I cover source maps configuration to debug TypeScript directly in the source code.
In praise of console.log
Print debugging — adding console.log statements to understand what's going on — is probably the oldest and most universal debugging technique. And it exists for good reasons.
Simplicity first
When a bug appears, the first question is always the same: "what's actually happening?". A well-placed console.log answers that question in seconds. No configuration, no tool to open, no breakpoint to set. You write it, save, and observe. As Tim Sneath explains in his article on print debugging, this technique "gives you permanent information that you can analyze at your own pace".
Let's look at a concrete example from nuqs, a library for managing query strings in React. In the safe-parse.ts file, we find this function:
// https://github.com/47ng/nuqs/blob/next/packages/nuqs/src/lib/safe-parse.ts
export function safeParse<I, R>(
parser: (arg: I) => R,
value: I,
key?: string,
): R | null {
try {
return parser(value)
} catch (error) {
warn(
'[nuqs] Error while parsing value `%s`: %O' +
(key ? ' (for key `%s`)' : ''),
value,
error,
key,
)
return null
}
}
This is print debugging built directly into production code. When parsing fails, it logs the error along with the value and the relevant key. It's immediate, readable, and works in any environment.
Where console.log excels
Print debugging shines in certain situations that the debugger handles poorly. Bugs related to timing are the perfect example.
The case of heisenbugs
A heisenbug — named after physicist Werner Heisenberg and his uncertainty principle — is a bug that disappears or changes behavior when you try to observe it. The term references the observer effect: the act of observing a system can alter its state.
In practice, when you set a breakpoint on asynchronous code, you alter the execution timing. Threads synchronize differently, timeouts elapse while you inspect variables, and the bug may simply stop manifesting. This is particularly insidious with race conditions.
Let's imagine this scenario in a React application:
function useUserData(userId: string) {
const [user, setUser] = useState<User | null>(null)
const [orders, setOrders] = useState<Order[]>([])
useEffect(() => {
// Ces deux appels partent en parallèle
fetchUser(userId).then(setUser)
fetchOrders(userId).then(setOrders)
}, [userId])
// Bug : parfois orders arrive avant user
// et on affiche "Commandes de undefined"
return { user, orders }
}
If you set a breakpoint in the .then(setUser), you slow down that branch. The bug where orders arrives before user may no longer occur — the debugger changed the timing. With console.log, the code runs at full speed:
useEffect(() => {
console.log(`[${Date.now()}] Début fetch user`)
fetchUser(userId).then((data) => {
console.log(`[${Date.now()}] User reçu:`, data)
setUser(data)
})
console.log(`[${Date.now()}] Début fetch orders`)
fetchOrders(userId).then((data) => {
console.log(`[${Date.now()}] Orders reçus:`, data.length)
setOrders(data)
})
}, [userId])
The timestamps reveal the actual execution order. If orders arrive before the user, you see it immediately in the console.
The lesser-known console variants
Most developers only use console.log, but the Console API offers much more. Chrome DevTools and Firefox Developer Tools document these features.
// Grouper des logs liés
console.group('Rendu du composant UserProfile')
console.log('Props:', props)
console.log('State:', state)
console.groupEnd()
// Afficher des objets en tableau
console.table(users) // Affiche un tableau formaté avec colonnes
// Mesurer le temps d'exécution
console.time('fetchData')
await fetchAllData()
console.timeEnd('fetchData') // Affiche "fetchData: 234.5ms"
// Assertions conditionnelles
console.assert(user.id !== undefined, 'User ID manquant!', user)
// Tracer la pile d'appels
console.trace('Comment est-on arrivé ici ?')
console.table is particularly useful for debugging arrays of objects — instead of hard-to-read JSON, you get a clean table with columns. console.time and console.timeEnd let you measure performance without leaving your code.
How breakpoints work
Before exploring why the debugger is useful, we need to understand what actually happens when you set a breakpoint.
The mechanism under the hood
When you set a breakpoint, the JavaScript engine (V8 in Chrome, SpiderMonkey in Firefox) inserts a special instruction into the compiled code. When execution reaches that instruction, the engine:
- Suspends execution of the main JavaScript thread
- Captures the full state: local variables, closures, call stack
- Notifies the debugger, which can then display this information
- Waits for a command: continue, step to the next line, step into a function...
This is why the debugger alters timing — execution is literally paused while you inspect.
The different types of breakpoints
Modern browsers offer several types of breakpoints, each useful in different situations.
The classic breakpoint: click on the line number in the Sources tab (Chrome) or Debugger tab (Firefox). Execution stops every time that line is hit.
The conditional breakpoint: right-click on the line number, then "Add conditional breakpoint". You enter a JavaScript expression — the breakpoint only triggers if the expression returns true. For example, userId === 'user-problematique' will only stop for that specific user.
The logpoint: right-click, then "Add logpoint" (Chrome) or "Add log action" (Firefox). It's a console.log without modifying source code. You enter the message to display, and it appears in the console every time that line is hit — without pausing. The Firefox documentation covers this feature in detail.
The debugger statement: a native JavaScript instruction. When the browser encounters debugger; with DevTools open, it stops immediately:
function processOrder(order: Order) {
if (order.items.length === 0) {
debugger // S'arrête ici pour inspecter pourquoi le panier est vide
}
// ...
}
Be careful: this statement can be committed by mistake. I recommend adding this rule to your ESLint configuration:
{
"rules": {
"no-debugger": "error"
}
}
With this rule, ESLint will block any commit containing a forgotten debugger.
What the debugger brings to the table
Despite all its advantages, console.log has a fundamental limitation: you can only log what you anticipate. If the bug comes from a variable you didn't think to display, you have to modify the code, reload, and start over. The debugger eliminates this problem.
Seeing the full program state
When execution stops on a breakpoint, you have access to all variables: local, closures, global scope, and the complete call stack. You can explore freely without having anticipated what to look at. As the HackerNoon article on the debugger explains, "with the debugger, you can examine the call stack, evaluate variables in their scope, and observe DOM mutations".
To access this information in Chrome DevTools:
- Open DevTools (F12 or Cmd+Option+I)
- Go to the Sources tab
- Set a breakpoint and trigger its execution
- The right panel displays several sections:
- Scope: all accessible variables (Local, Closure, Global)
- Call Stack: the chain of calls that led here
- Watch: custom expressions to monitor
In Firefox, it's the Debugger tab with a similar layout. The Firefox documentation details the interface.
Modifying values on the fly
In the Scope panel, you can directly change a variable's value and continue execution with the new value. Imagine: you suspect a bug occurs when items is an empty array. Instead of modifying the code to test this case, you set a breakpoint, double-click on the items value in the Scope panel, change it to [], and continue. The feedback is instant.
The call stack for understanding React re-renders
The call stack (Call Stack) is particularly valuable for debugging React. It shows exactly how you arrived at this point in the code — which chain of functions led here.
Let's look at a concrete example. In nuqs, the patch-history.ts file shows a monkey-patching pattern on the History API:
// https://github.com/47ng/nuqs/blob/next/packages/nuqs/src/adapters/lib/patch-history.ts#L104-L115
history.pushState = function nuqs_pushState(state, marker, url) {
originalPushState.call(history, state, '', url)
if (url && marker !== historyUpdateMarker) {
sync(url)
}
}
If you set a breakpoint on the sync(url) line and look at the call stack, you'll see exactly who called history.pushState: was it your code? Another component? The routing library? This information is nearly impossible to obtain with console.log — you would need to instrument every potential caller.
For an unexpected React re-render, the same technique applies. Set a breakpoint in your component, and the call stack will show the chain: workLoopSync -> performUnitOfWork -> beginWork -> ... -> your component. By walking back up, you can identify which state or props change triggered that render.
Debugging React: dedicated tools
React adds a layer of abstraction that makes debugging more complex. The virtual DOM, hooks, re-renders — all of this creates behaviors that can be difficult to understand with standard tools. Fortunately, the ecosystem provides dedicated tools.
React Developer Tools
The React Developer Tools extension is extremely useful for understanding your application's state. It adds two tabs to DevTools: "Components" and "Profiler".
The Components tab displays the React component tree — not the HTML DOM, but the actual component hierarchy. By selecting a component, you can see its current props, its state, and the hooks it uses. DigitalOcean offers a detailed tutorial on using this tab.
What's particularly handy is the ability to modify state and props directly in DevTools. You can change isEditing from false to true and immediately see the component re-render with the new state.
Tracking unnecessary re-renders
Excessive re-renders are a frequent source of performance issues in React. The Profiler tab in React DevTools lets you visualize them. You start a recording, interact with the application, stop — and you see exactly which components re-rendered, how many times, and how long each render took.
To go further, there's the "Highlight updates when components render" option in React DevTools settings. Each re-render is visually highlighted with a colored flash around the component.
The Why Did You Render library by Welldone Software takes this concept even further. It monkey-patches React to display in the console why a component re-rendered.
A monkey-patch is a technique that involves modifying the behavior of an existing object or function at runtime, without touching the original source code. In the case of Why Did You Render, the library replaces certain internal React functions with instrumented versions that log additional information. This is exactly what nuqs does with the History API in the previous example — history.pushState is replaced with a version that calls the original and then executes additional code.
// Configuration dans un fichier séparé (ex: wdyr.ts)
import React from 'react'
if (process.env.NODE_ENV === 'development') {
const whyDidYouRender = require('@welldone-software/why-did-you-render')
whyDidYouRender(React, {
trackAllPureComponents: true,
})
}
// Puis sur un composant spécifique
function ExpensiveList({ items }: { items: Item[] }) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
)
}
ExpensiveList.whyDidYouRender = true
Warning: this library should never be used in production. It significantly slows down React and can cause unexpected behavior.
Debugging hooks
React hooks can be particularly difficult to debug. useEffect hooks that run in infinite loops are a classic problem. LogRocket has documented the common patterns that cause these loops.
The most insidious pattern: a setState call directly in the body of a useEffect, with no exit condition.
Another classic case: an object in the dependencies with an API that returns different data on each call (pagination, timestamps, etc.).
function usePaginatedData() {
const [items, setItems] = useState<Item[]>([])
// Nouvelle référence à chaque render
const params = { page: 1, timestamp: Date.now() }
useEffect(() => {
// Boucle : params change → fetch → setItems → re-render → params change...
fetchItems(params).then((newItems) => setItems([...items, ...newItems]))
}, [params])
return items
}
The solution: declare constant objects outside the component, or use useMemo with primitive dependencies. In nuqs, this pattern is solved with useMemo and a dependency on JSON.stringify:
// https://github.com/47ng/nuqs/blob/next/packages/nuqs/src/useQueryStates.ts#L93-L99
const resolvedUrlKeys = useMemo(
() =>
Object.fromEntries(
Object.keys(keyMap).map((key) => [key, urlKeys[key] ?? key]),
),
[stateKeys, JSON.stringify(urlKeys)],
)
To debug these situations, a console.log inside the useEffect with a counter can reveal the problem:
const renderCount = useRef(0)
useEffect(() => {
renderCount.current++
console.log(`Effect exécuté ${renderCount.current} fois`)
// Si ce nombre explose, vous avez une boucle infinie
}, [dependency])
Debugging TypeScript: source maps and configuration
TypeScript compiles to JavaScript. Without proper configuration, the debugger shows the generated JavaScript, not the original TypeScript. Source maps solve this problem.
Configuring source maps
In tsconfig.json, the sourceMap: true option tells the compiler to generate .map files that establish the mapping between TypeScript code and the generated JavaScript. The VSCode documentation details this configuration.
{
"compilerOptions": {
"target": "ES5",
"module": "CommonJS",
"outDir": "out",
"sourceMap": true
}
}
With this configuration, when you set a breakpoint in a .ts file, the debugger knows exactly which line of JavaScript corresponds. You debug in the code you wrote, not in the generated code.
TypeScript errors at runtime
TypeScript checks types at compile time, not at runtime. Once compiled, the JavaScript no longer has any type information. This means that incorrectly typed data coming from an external API can go unnoticed until it causes a bug.
interface User {
id: string
name: string
email: string
}
async function getUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`)
return response.json() // TypeScript fait confiance, mais l'API pourrait renvoyer autre chose
}
// Si l'API renvoie { id: 123, name: null }, TypeScript ne le détectera pas
const user = await getUser('123')
console.log(user.name.toUpperCase()) // Crash: Cannot read property 'toUpperCase' of null
This is exactly why nuqs uses the safeParse function seen earlier — it wraps the parsing in a try/catch and logs the error cleanly instead of crashing silently.
My daily practice
After years of practice, here's how I approach debugging.
For simple, isolated bugs — a variable that doesn't have the expected value, a condition that doesn't behave as planned — console.log remains my first instinct. It's fast, it works everywhere, and it leaves a trace I can analyze.
For complex bugs that involve multiple functions, shared state, or behaviors that are hard to reproduce, I switch to the debugger. The ability to navigate the call stack and inspect the full program state is irreplaceable in those situations.
For React performance issues, React DevTools and its Profiler are extremely useful. Combining that with Why Did You Render in development makes it easy to quickly identify unnecessary re-renders — even though it's not always needed systematically.
And above all: I clean up my console.log statements before committing. An ESLint no-console rule in warning mode reminds me to do it. Browser logpoints have become my preferred alternative — they provide the same information without polluting the code.
Debugging isn't a binary skill that you either master or don't. It's a set of tools and techniques that you learn to combine depending on the context. console.log and the debugger aren't competing — they're complementary.
Struggling with a particularly nasty React or TypeScript bug? Let's talk about it — debugging is what I love.