Home / Articles

Closures, hoisting, and this: JavaScript rules that show up in bugs

Hoisting, closures and this in JavaScript, taught through the bugs that make them stick: TDZ errors, shared var bindings in loops and detached methods.

Publisher: REACTAPP.TOP5 min read1108 words

This article is published in English.

Sources: Closures, Hoisting, and `this`: The JavaScript Concepts That Finally Clicked for Me

Closures, Hoisting, and this: The JavaScript Concepts That Keep Surfacing

Plenty of developers ship full-stack JavaScript for months while still treating three language details as interview trivia: hoisting, closures, and this. Memorized definitions can pass a screen. Production bugs expose the gaps—timeouts logging the wrong index, handlers losing their instance, variables that exist as undefined instead of throwing. The cost is usually time: staring at a stack that looks correct until the call-site rules finally explain it.

The notes below are not a glossary. They walk through the moments those three ideas usually stop being abstract and start explaining real failures. Each section starts from a small program that surprises, then names the rule that makes the surprise inevitable.

Hoisting

A first surprise looks like this:

console.log(greet()); // "Hello!"
function greet() {
  return "Hello!";
}

The call sits above the declaration, yet it returns "Hello!". A top-to-bottom mental model says that should be impossible. Function declarations are different: before statements run, the engine lifts the full declaration—including the body—so greet is already callable.

A nearby pattern behaves differently:

console.log(name); // undefined
var name = "Prashant";

There is no ReferenceError, only undefined. The engine knew name existed early, but the assignment had not run yet. Both cases share one mechanism. Declarations are hoisted to the top of their scope. Function declarations bring their bodies along. var brings only the binding; the initializer stays where it was written, so the early value is empty.

Switching the binding to let changes the failure mode:

console.log(name); // ReferenceError: Cannot access 'name' before initialization
let name = "Prashant";

This throws. A common instinct is to say let “does not hoist.” That is inaccurate. The binding is created early, then locked in the temporal dead zone until the declaration line. The name exists; touching it before initialization is illegal. That default is safer than var quietly handing out undefined and letting the bug travel further.

Closures

Closures often click through a loop bug rather than a textbook sentence. Consider a delayed log that should print 1, 2, 3:

for (var i = 1; i <= 3; i++) {
  setTimeout(() => {
    console.log(i);
  }, 1000);
}
// what I expected: 1, 2, 3
// what I got: 4, 4, 4

The expectation is sequential numbers. The result is 4, 4, 4. var is function-scoped, not block-scoped, so the loop shares one i. When the timers fire after the loop finishes, that shared binding already sits at 4. Every callback reads the same finished value.

One keyword fixes it:

for (let i = 1; i <= 3; i++) {
  setTimeout(() => {
    console.log(i);
  }, 1000);
}
// 1, 2, 3

let is block-scoped, so each iteration owns a distinct i. Each callback closes over its own binding.

That pairing—function plus remembered bindings—is the practical definition of a closure. The setTimeout callback is not only a function; it carries the environment it needs after the loop scope has ended. Once you expect that retention, the 4, 4, 4 result stops looking like a platform bug and starts looking like shared mutable state.

Once visible, closures show up throughout ordinary React and Node code: hooks that return functions, event handlers that read component state, and almost every useState updater. The idea is not interview-only; it is how a large fraction of callback-driven UI code works.

this

this is confusing because the same function body can see different values depending on how it is invoked, not where it was written.

A class counter illustrates the trap:

class Counter {
  constructor() {
    this.count = 0;
  }
  increment() {
    this.count++;
    console.log(this.count);
  }
}const counter = new Counter();
const btn = document.querySelector("button");btn.addEventListener("click", counter.increment); // this is undefined

Calling increment through the instance works. Passing counter.increment straight into addEventListener often fails with this as undefined inside the method. Detaching the method produces a bare function call; in strict mode that call does not receive the original object as this.

Two repairs make the rule memorable:

// bind it explicitly
btn.addEventListener("click", counter.increment.bind(counter));
// or wrap it in an arrow function
btn.addEventListener("click", () => counter.increment());

.bind(counter) locks the receiver. An arrow wrapper calls through the instance so the method invocation form is preserved. Arrow functions do not invent their own this; they inherit the surrounding scope’s binding, which is why the second fix needs no explicit bind.

Four call-site rules cover most day-to-day cases:

  1. A plain function call → this is undefined in strict mode, or the global object otherwise.
  2. obj.method() → this is obj.
  3. .call(), .apply(), or .bind() → this is whatever was passed explicitly.
  4. Arrow functions → no own this; they inherit from the enclosing scope.

That last rule is why arrow handlers are a common default inside classes and components that touch instance fields: they avoid accidental detachment bugs.

What actually helped

Reading definitions alone rarely sticks. Practices that do:

Write tiny, intentionally broken snippets—five or six lines aimed at one failure—so the break is easy to observe and reverse-engineer.

Predict console.log output before running the file. Forcing a forecast turns a memorized rule into a checkable hypothesis.

Explain the mechanism out loud. Gaps surface immediately when hoisting or closures must be stated in fresh words.

Takeaway

Hoisting, closures, and this are not trivia. They explain production failures: shared loop bindings, TDZ errors, and handlers that lose their instance. They also fail to map cleanly onto many other languages, so confusion early on is normal. Deliberate breakage, prediction, and restatement shorten the time until the behavior feels predictable. Keep a scratch file of broken snippets; revisiting them after a week is often more useful than rereading a long chapter.