Scope determines where a variable is visible and how JavaScript resolves a name to a value. It's the single idea underneath closures, hoisting, the temporal dead zone, and half of the 'why is this undefined' bugs you'll ever debug.
function outer() {
var a = 1;
if (true) {
var a = 2;
}
console.log(a);
}
outer();
Interview Questions
3 questions
Cheatsheet
Download-ready reference
Cheatsheet
Lexical scoping: scope is fixed at DEFINITION time, based on source position — never the call site.
var: function-scoped, hoisted + initialized to undefined
let / const: block-scoped, hoisted but in TDZ until the declaration line runs
Scope chain lookup order:
current scope → enclosing function scope(s) → module/global scope → ReferenceError
Shadowing: an inner declaration with the same name as an outer one wins
inside its own scope; the outer binding is unaffected outside that scope.
Module pattern: wrap state in a function scope, return only the
methods you want public — everything else is unreachable, permanently.
Rule of thumb: const by default, let when reassignment is needed, var never.
The beginner framing: scope is the region of your code where a given variable name means something. Step outside that region, and the name either refers to something else or doesn't exist at all.
The precise mental model: JavaScript uses lexical scoping — scope is determined entirely by where you write code, not by how or when it's called. Every function creates a new scope the moment it's defined, not the moment it's called. That scope has a parent — whatever scope physically surrounds it in the source — and that parent chain is fixed forever, regardless of the call stack.
The engine-level view: each scope is backed by a Lexical Environment — a record of bindings (variables/functions) plus a pointer to its parent environment. When you reference a name, the engine walks this pointer chain outward — current scope, then parent, then grandparent, up to the global scope — until it finds a binding or runs out of scopes (ReferenceError). This walk is the scope chain, and it's resolved at parse time based on nesting, which is exactly why it's called lexical (from "lexeme" — the literal text structure) rather than dynamic scoping.
const x = "global";function outer() { const y = "outer"; function inner() { const z = "inner"; console.log(x, y, z); // "global outer inner" } inner();}outer();
inner can see x, y, and z because each is either its own binding or reachable by walking up the chain: inner → outer → global.
Scope chain lookup
step 1 / 5
const global = "🌍";
function outer() {
const outerVar = "outer";
function inner() {
const innerVar = "inner";
console.log(innerVar, outerVar, global);
}
inner();
}
outer();
Global
global: "🌍"
Global scope is created first — the `global` binding lives here.
var is function-scoped — it only respects function boundaries, ignoring if, for, and bare {} blocks. let and const are block-scoped — they respect every pair of curly braces.
function demo() { if (true) { var a = 1; let b = 2; } console.log(a); // 1 — var leaked out of the if-block console.log(b); // ReferenceError — b is out of scope}
This is the practical reason let/const replaced var: block scope matches what your eyes expect from the indentation.
A variable declared in an inner scope with the same name as an outer one shadows it — the inner binding wins for any code inside that scope, and the outer one is untouched.
const value = "outer";function reveal() { const value = "inner"; // shadows the outer `value` console.log(value); // "inner"}reveal();console.log(value); // "outer" — unaffected
Shadowing isn't a bug by itself, but shadowing by accident (usually a typo'd parameter name) is one of the more confusing classes of bugs to spot, because the code reads correctly — it just refers to the wrong binding.
Predict what each console.log prints before running it, then check yourself.
let mode = "light";function render() { console.log("before:", mode); if (mode === "light") { let mode = "dark"; // shadows the outer `mode` for this block only console.log("inside:", mode); } console.log("after:", mode);}
Solution
before: lightinside: darkafter: light
The let mode inside the if block creates a brand-new binding scoped to that block. It shadows the outer mode only within the block — the moment execution leaves the { }, the outer mode is visible again, completely untouched by the shadowing.
Build a createNamespace helper that uses scope — not an object literal — to keep internal state private, exposing only the methods you choose:
function createNamespace() { // Everything in here is invisible from the outside — // there is no way to reach `registry` except through the // functions we explicitly return. const registry = new Map(); function register(key, value) { registry.set(key, value); return this; } function resolve(key) { return registry.
This is the module pattern: a function scope standing in for a private keyword JavaScript never had (until real #private class fields arrived). Every piece of state you don't return is invisible to the outside world, permanently — not by convention, but because there is no path in the scope chain that reaches it.
Every closure gotcha in React traces back to scope. When you write:
function Counter() { const [count, setCount] = useState(0); function handleClick() { setTimeout(() => { console.log(count); // whichever `count` was in scope THIS render }, 3000); } return <button onClick={handleClick
handleClick is redefined on every render, and each version closes over that render's own count binding — not a shared, mutable variable. Click twice quickly and you'll get two different logged values from two different closures, each frozen at its own render's scope. This is exactly the scope-chain mechanism above; React doesn't add anything new, it just re-runs your component function (and therefore recreates its scope) on every render.
let user = { name: "Alice" };function greet(user) { // parameter `user` shadows the outer `user` — // this function can never see the outer one. console.log(`Hi, ${user.name}`);}
Not a bug here, but rename either the parameter or the outer variable once a file grows — silent shadowing is a common source of "I changed the value but nothing happened" confusion.
3. Believing scope is determined by the call site#
const name = "global";function outer() { const name = "outer"; inner();}function inner() { console.log(name); // "global" — NOT "outer"}outer();
inner is defined in the global scope, so its parent scope is global — regardless of the fact that it's called from inside outer. This is the difference between lexical and dynamic scoping, and it trips up anyone coming from a dynamically-scoped background (or anyone who hasn't internalized that scope is fixed at definition time).
Default to const, fall back to let, avoid var. This alone eliminates function-scope leakage and most TDZ surprises.
Keep scopes small. A variable declared as close as possible to where it's used is easier to reason about — and shadowing becomes a feature (clear intent) rather than a trap.
Avoid shadowing built-ins and outer variables unless the shadowing is the entire point (like a parameter intentionally named the same as a module-level default).
One let/const per line, declared where first used — resist the C-style habit of declaring everything at the top of a function; it fights against block scope instead of using it.
Scope chain lookups aren't free — each unresolved reference means walking outward one scope at a time. In practice V8's JIT optimizes this heavily for "monomorphic" access patterns, so it's rarely a real bottleneck, but deeply nested closures accessed in hot loops are a case where flattening scope (e.g., passing a value as a parameter instead of closing over it) can measurably help.
Every scope you create (function, block) is a small allocation. It's not something to obsess over, but avoid creating functions inside tight loops when a function defined once outside the loop would do the same job — you're paying for a new scope on every iteration for no benefit.