Buy now
7.1

Fundamentals

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.

← To contents

JavaScript for Rails Developers

Choose one of the three tiers below

Get your copy!

Solo

$ 49

What is included?

  • Full book (PDF/ePub)
  • Code-base included

Professional

$ 79

What is included?

Team (5 developers)

$ 179

What is included?

  • Full book (PDF/ePub)
  • Full code-base access
  • Share with 5 developers in your team