Concept
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.
const global = "🌍";function outer() {const outerVar = "outer";function inner() {const innerVar = "inner";console.log(innerVar, outerVar, global);}inner();}outer();
Global scope is created first, the `global` binding lives here.
Function scope vs. block scope
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.
Shadowing
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", unaffectedShadowing 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.
Try It
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);
}
render();Solution
before: light
inside: dark
after: lightThe 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.
Implement It Yourself
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.get(key);
}
function list() {
return [...registry.keys()];
}
return { register, resolve, list };
}
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.
In React
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}>{count}</button>;
}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.
Common Mistakes
1. Assuming var respects block scope
for (var i = 0; i < 3; i++) {}
console.log(i); // 3, `i` leaked into the enclosing scopeFix: use let unless you specifically need function-scoped behavior (rare in modern code).
2. Accidental shadowing via parameter names
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).
Best Practices
- Default to
const, fall back tolet, avoidvar. 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/constper 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.
Performance Tips
- 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.
