Debugging in JavaScript is the process of identifying, isolating, and fixing errors and bugs in code through systematic inspection and testing using tools like console.log(), debugger statements, and browser developer tools.
7.1.1 Beyond console.log
You most likely are known with console.log. console is a global variable, and all global variables are, as you have read before, properties of the window object. This means window.console.log and console.log are effectively the same thing. It is still a great debugging tool today, but it has quite a few more tricks up its sleeve. Before exploring those, let's add a little helper to make us, lazy developers, a little bit more productive. Head over to your app/javascript/application.js:
// app/javascript/application.js
// …
window.log = (...params) => console.log(...params)
Now you can write: log("Hello from Rails Designer!")
instead. 7 characters saved × 0,24 seconds per character × 15 logs = 25,2 seconds saved per debugging session!
You are not limited to just text or single variables. Multiple arguments can be passed. And also string substitution is supported.
console.log("User:", { name: "Kendall", age: 30 })
console.log("Status:", "active", "since:", new Date())
console.log("%s is the %s book for Rails devs to finally grasp JavaScript", ""JavaScript for Rails Devs"", "best")
7.1.1.1 Console logging levels
There are many other logging levels available, just like with ActiveSupport::Logger.
console.info("Info message") // for important information, usually positive/success flows
console.warn("Warning message") // issues, deprecated features, things to watch out for
console.error("Error message") // actual errors, exceptions, critical issues
console.debug("Debug message") // info for debugging, often filtered out in production
Each level has different colors and icons in the developer tools. You can also filter or search by log level and configure which ones to show or hide. You can choose to hide certain levels, like debug. Each logging level supports the same attributes as seen for log()
.
After reading this book so far; do you have an idea how to extend the above helper method (log()
) to include all logging levels? Check out the included resources to see how it's done.
7.1.1.2 More console utilities
console.trace
outputs the current stack trace to the console, which will help you track the execution path and function call hierarchy that led to a specific point in your code. Adding at the top of the app/javascript/controllers/editor_controller.js outputs:
console.trace() editor_controller.js:54:12
connect editor_controller.js:54
connect stimulus.js:1498
connectContextForScope stimulus.js:1668
scopeConnected stimulus.js:2054
elementMatchedValue stimulus.js:1961
tokenMatched stimulus.js:999
// etc.
This trace shows Stimulus' controller lifecycle, where the connect() hook in the editor_controller.js on line 54 was called as part of Stimulus's initialization sequence. It then first matches the DOM element with its controller through tokenMatched, sets up the scope (scopeConnected), and finally executes the controller's connect() method.
This can help you find out which function led to a particular method being called, identify unexpected function calls or debug race conditions.
console.assert(false, "NUCLEAR LAUNCH DETECTED")
is a debugging method that tests if a condition is, in this case, false. Good for deliberately triggering an error during development or for adding impossible state checks in code (like below example). You can pass it any assertion:
function gettingGood(level) {
console.assert(level >= 0 && level <= 100,
"Getting good at JavaScript can only be between 0 - 100!"
)
// getting good logic here…
}
The console.table()
method displays array or object data in a formatted table in your browser's console. Useful at times to read, analyze or compare. Could be used to list performance monitoring data, API responses or configuration between development and production.
const users = [
{ id: 1, name: "Kendall", role: "admin", lastLogin: "1995-12-21" },
{ id: 2, name: "Cam", role: "user", lastLogin: "2004-07-01" }
]
console.table(users)
With console.dir()
you can display a list of properties for a specified object with customizable output formatting. It is not something you are likely to use much, but it is there if you need it.
console.dir(document.body)
console.dir(document.querySelector("[data-controller='editor']"))
console.dir(document.forms[0])
console.dir(this.element) // when inside a Stimulus controller
Console groups create collapsible sections in your console, which helps organize related log messages into nested, indented blocks. Use it like so:
processOrder() {
console.group("Checkout Process")
console.log("1. Validating cart…")
this.validateCart()
console.log("2. Processing payment…")
this.processPayment()
console.log("3. Creating order…")
this.createOrder()
console.groupEnd()
}
Similar to console.group
, console.time()
wraps around logic and can measure execution time (in milliseconds) between start and end points. The labels for time and timeEnd need to be exactly the same.
console.time("Controller setup")
this.editor = new EditorView({
doc: this.contentValue,
parent: this.element,
extensions: this.#extensions
})
console.timeEnd("Controller setup")
Then there is also console.timeLog("Controller setup", "some custom value")
. It allows you to see intermediate timing measurements without stopping the timer.
connect() {
console.time("Controller setup")
this.collaboration = new Collaboration(this.contributorValue?.id)
console.timeLog("Controller setup", "Before EditorView instantiation")
this.editor = new EditorView({
doc: this.contentValue,
parent: this.element,
extensions: this.#extensions
})
console.timeLog("Controller setup", "After EditorView instantiation")
this.collaboration.attach({ to: this.editor })
console.timeEnd("Controller setup")
}
console.count()
helps debug code by counting how many times a specific operation occurs (using a required string label as the identifier). Then use console.countReset()
to restart that count from zero using the same label. Useful for tracking API calls, retries, or any repeating operation you want to monitor.
class Stripe {
async fetch() {
console.count("api-call")
try {
const response = await fetch("https://api.stripe.com/v1/customers")
console.countReset("api-call")
return response.json()
} catch (error) {
if (this.retries < 3) {
await this.delay(1000)
return this.fetch()
}
throw error
}
}
}
And you can of course use them altogether:
async function confirmPaymentIntent(clientSecret) {
console.group("Stripe Payment")
console.time("request")
console.log("Intent:", { clientSecret: `${clientSecret.slice(0, 10)}…` })
// Simulate Stripe API call
await new Promise(resolve => setTimeout(resolve, 300))
console.table([
{ status: "processing", time: new Date().toLocaleTimeString() }
])
console.timeEnd("request")
console.groupEnd()
return await stripe.confirmPayment({ clientSecret })
}
Then when done, or you feel like spring cleaning, console.clear()
method clears the console output in your browser's developer tools. But you can also use the keyboard shortcut (typically CMD+K
for most browsers on macOS).
console.clear()
7.1.2 Understanding the call stack
JavaScript's call stack tracks the execution of function calls in your code. It follows a Last-In-First-Out (LIFO) principle, where the most recently called function is the first to complete. When a function is called, it's pushed onto the stack; when it returns, it's popped off.
This is a big difference from Ruby (when using a server like Puma or Passenger). Ruby maintains multiple call stacks (one for each thread). This allows code to truly execute simultaneously across different threads. So in Ruby multiple LIFO call stacks can process function calls in parallel, rather than JavaScript's single queue of execution.
Compare this Ruby code…
def get_user(id)
puts "Fetching user #{id}…"
uri = URI("https://jsonplaceholder.typicode.com/users/#{id}")
response = Net::HTTP.get(uri)
user = JSON.parse(response)
puts "Got #{user['name']}"
user
end
[1, 2].each { get_user(it) }
…to this JavaScript code:
function getUser(id) {
console.log(`Fetching user ${id}…`)
return fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
.then(response => response.json())
.then(user => {
console.log(`Got ${user.name}`)
return user
})
}
[1, 2].forEach(id => getUser(id))
Ruby's output will be:
# Fetching user 1…
# Got
# Fetching user 2…
# Got
While JavaScript's output will be:
// Fetching user 1…
// Fetching user 2…
// Got
// Got
Knowing this, you should remember that JavaScript's single-threaded nature means operations don't automatically wait for each other like in Ruby. When you make API calls, or compute-expensive operations or handle file operations for example, you need to explicitly manage the sequence using promises or async/await, rather than assuming each line will complete before moving to the next.
7.1.3 Debugging scope issues
You might run into scope issues when variables aren't accessible where you expect them to be. These often occur with nested functions, closures, or block-scoped declarations. The most common problems have been covered in this book, but it's worth listing a few more common issues: variables being undefined due to temporal dead zones with let and const and accidentally creating global variables by forgetting declarations.
7.1.3.1 Temporal Dead Zone
The Temporal Dead Zone (TDZ) is the period between entering a scope (block, function, or module) and the actual declaration of a let or const variable, where any attempt to access the variable will throw a ReferenceError instead of returning undefined like var does.
function show() {
console.log(varName) // => undefined
var varName = "Cam"
console.log(varName) // => "Bob"
console.log(letName) // => ReferenceError
let letName = "Kendall"
console.log(letName) // => "Alice"
console.log(constName) // => ReferenceError
const constName = "Riley"
console.log(constName) // => "Charlie"
constName = "Jordan" // => TypeError: Assignment to constant variable
}
var
can be used before it's declared (giving undefined). But both let
and const
must be declared before use or they throw an error (ReferenceError). After declaration, var
and let
can be reassigned, but const
will throw a TypeError if you try to reassign it.
This difference makes const and let safer because they will let(pun!) you know immediately if you're trying to use a variable too early and var silently gives you undefined. In a way you can compare const/let and var, to respectively to Rails' update! and update.
7.1.3.2 Accidentally creating global variables
Because Ruby automatically scopes variables based on their first usage (e.g. user_name = "Rails Designer"
), a common pitfall for Ruby developers, is forgetting to declare variables with var, let, or const before using them. This can lead to namespace pollution and hard-to-debug issues, as the variable gets attached to the global window object instead of being properly scoped to the current function or block.
function sayHi() {
userName = "Kendall"
console.log(userName)
}
This error is easily spotted in Stimulus controllers (or any JavaScript class) because they execute code in strict mode (a way to opt into a restricted variant of JavaScript that helps you write safer code) and thus will raise a ReferenceError: assignment to undeclared variable userName
. For other JavaScript file you can add use strict
at the top of the file. Also if you use ESLint with the no-undef rule enabled, it will scream at you for the undeclared variable during development.