JavaScript — Variables & Scoping
Variables are the fundamental building blocks of any JavaScript program. They store data values, reference objects, and hold state throughout the lifecycle of an application. Understanding how variables work — their declaration, assignment rules, scoping behavior, and hoisting mechanics — is essential for writing predictable, bug-free code.
JavaScript provides three keywords for declaring variables: var, let, and const. Each has distinct rules about scope, reassignment, redeclaration, and hoisting. Modern JavaScript strongly favors const by default and let only when reassignment is necessary. The legacy var should be avoided in new code.
In addition to declaration mechanics, JavaScript's scoping rules determine where variables are accessible — from the global scope down to function and block scopes. The scope chain, lexical scoping, and closures build on these foundational rules. This page covers all aspects of variables and scoping in depth.
| 1 | // Three ways to declare a variable |
| 2 | var legacy = "function-scoped, hoisted, avoid me"; |
| 3 | let mutable = "block-scoped, reassignable"; |
| 4 | const immutable = "block-scoped, cannot reassign"; |
| 5 | |
| 6 | // The modern rule: |
| 7 | // Use const by default, let when you need reassignment |
| 8 | // Never use var in new code |
The choice between var, let, and const determines how a variable behaves across scope, hoisting, and reassignment. Understanding these differences is critical for writing correct JavaScript.
| Feature | var | let | const |
|---|---|---|---|
| Scope | Function | Block | Block |
| Reassignable | ✓ | ✓ | ✗ |
| Redeclarable | ✓ | ✗ | ✗ |
| Hoisted | ✓ (undefined) | ✓ (TDZ) | ✓ (TDZ) |
| Global property | ✓ (window.varName) | ✗ | ✗ |
| 1 | // const — cannot be reassigned, but object properties can mutate |
| 2 | const PI = 3.14159; |
| 3 | PI = 3; // TypeError: Assignment to constant variable |
| 4 | |
| 5 | const user = { name: "Alice" }; |
| 6 | user.name = "Bob"; // OK — mutating property, not reassigning |
| 7 | user = {}; // TypeError: reassigning const reference |
| 8 | |
| 9 | const array = [1, 2, 3]; |
| 10 | array.push(4); // OK — mutation allowed |
| 11 | array = []; // TypeError |
| 12 | |
| 13 | // let — block-scoped and reassignable |
| 14 | let count = 0; |
| 15 | count = 1; // OK |
| 16 | count = 2; // OK |
| 17 | let count = 3; // SyntaxError: redeclaration |
| 18 | |
| 19 | // var — function-scoped, hoisted, redeclarable |
| 20 | var x = 10; |
| 21 | var x = 20; // OK — redeclaration works (no error) |
| 22 | console.log(x); // 20 |
| 23 | |
| 24 | // var ignores block scope |
| 25 | if (true) { |
| 26 | var leaked = "I escape!"; |
| 27 | } |
| 28 | console.log(leaked); // "I escape!" — var is function-scoped, not block-scoped |
| 29 | |
| 30 | // let/const respect block scope |
| 31 | if (true) { |
| 32 | let blocked = "I stay in the block"; |
| 33 | const alsoBlocked = "Me too"; |
| 34 | } |
| 35 | console.log(blocked); // ReferenceError |
best practice
Scope determines where a variable is accessible in your code. JavaScript has three levels of scope: global (accessible everywhere), function (accessible within a function), and block (accessible within a { } block). The interplay between these scopes forms the foundation of JavaScript's lexical scoping model.
Global Scope
Variables declared outside any function or block are in the global scope. They are accessible from anywhere in the program. In browsers, global var declarations become properties of the window object, while global let and const do not.
| 1 | // Global scope — accessible everywhere |
| 2 | const globalConst = "global const"; |
| 3 | let globalLet = "global let"; |
| 4 | var globalVar = "global var"; // becomes window.globalVar in browsers |
| 5 | |
| 6 | function testScope() { |
| 7 | console.log(globalConst); // "global const" |
| 8 | console.log(globalLet); // "global let" |
| 9 | console.log(globalVar); // "global var" |
| 10 | } |
| 11 | |
| 12 | // var creates a property on the global object (window in browsers) |
| 13 | console.log(window.globalVar); // "global var" (var only) |
| 14 | console.log(window.globalLet); // undefined (let does not) |
| 15 | console.log(window.globalConst); // undefined (const does not) |
| 16 | |
| 17 | // Avoid creating globals implicitly |
| 18 | function bad() { |
| 19 | implicitGlobal = "I'm global!"; // no declaration keyword! |
| 20 | } |
| 21 | bad(); |
| 22 | console.log(implicitGlobal); // "I'm global!" — accidental global |
Function Scope
Variables declared with var inside a function are scoped to that function. They are accessible anywhere within the function, regardless of block nesting. let and const also respect function scope but are additionally constrained by block scope within the function.
| 1 | function scopeDemo() { |
| 2 | var functionScoped = "accessible everywhere in this function"; |
| 3 | let blockScoped = "only in this block or child blocks"; |
| 4 | const alsoBlockScoped = "same as let"; |
| 5 | |
| 6 | if (true) { |
| 7 | var stillAccessible = "var ignores blocks"; |
| 8 | let blockOnly = "trapped in this if block"; |
| 9 | const alsoBlockOnly = "trapped too"; |
| 10 | |
| 11 | console.log(functionScoped); // OK — function scope |
| 12 | console.log(blockScoped); // OK — parent scope |
| 13 | console.log(stillAccessible); // OK — same function scope |
| 14 | } |
| 15 | |
| 16 | console.log(stillAccessible); // "I escape!" — var ignores blocks |
| 17 | console.log(blockOnly); // ReferenceError — block-scoped |
| 18 | console.log(alsoBlockOnly); // ReferenceError — block-scoped |
| 19 | } |
| 20 | |
| 21 | scopeDemo(); |
| 22 | console.log(functionScoped); // ReferenceError — function scope |
Block Scope
Block scope was introduced in ES6 with let and const. A block is defined by a pair of curly braces { } — this includes if, for, while, switch, and bare blocks. Variables declared with let or const are confined to the block in which they are defined.
| 1 | // Bare blocks create scope for let/const |
| 2 | { |
| 3 | let a = 10; |
| 4 | const b = 20; |
| 5 | var c = 30; // var escapes! |
| 6 | } |
| 7 | console.log(c); // 30 |
| 8 | console.log(a); // ReferenceError |
| 9 | console.log(b); // ReferenceError |
| 10 | |
| 11 | // Block scope in loops |
| 12 | for (let i = 0; i < 3; i++) { |
| 13 | // Each iteration gets its own 'i' |
| 14 | setTimeout(() => console.log(i), 100); // 0, 1, 2 |
| 15 | } |
| 16 | |
| 17 | for (var j = 0; j < 3; j++) { |
| 18 | // Only one 'j' — shared across all iterations |
| 19 | setTimeout(() => console.log(j), 100); // 3, 3, 3 |
| 20 | } |
| 21 | |
| 22 | // Block scope in switch |
| 23 | const value = "b"; |
| 24 | switch (value) { |
| 25 | case "a": { |
| 26 | let result = "Case A"; |
| 27 | console.log(result); |
| 28 | break; |
| 29 | } |
| 30 | case "b": { |
| 31 | let result = "Case B"; // Different scope — no conflict |
| 32 | console.log(result); |
| 33 | break; |
| 34 | } |
| 35 | } |
info
Hoisting is JavaScript's behavior of moving declarations to the top of their containing scope during compilation. The key distinction is how each declaration type is hoisted: var is hoisted and initialized to undefined, while let and const are hoisted but not initialized — they enter the Temporal Dead Zone.
| Declaration | Hoisted? | Initial Value | Accessible Before Declaration? |
|---|---|---|---|
| var | ✓ | undefined | ✓ (returns undefined) |
| let | ✓ | uninitialized (TDZ) | ✗ (ReferenceError) |
| const | ✓ | uninitialized (TDZ) | ✗ (ReferenceError) |
| function | ✓ | the function itself | ✓ (full hoisting) |
| class | ✓ | uninitialized (TDZ) | ✗ (ReferenceError) |
| 1 | // var hoisting — declaration moves up, value stays |
| 2 | console.log(a); // undefined (not ReferenceError!) |
| 3 | var a = 5; |
| 4 | // The above is interpreted as: |
| 5 | // var a; |
| 6 | // console.log(a); |
| 7 | // a = 5; |
| 8 | |
| 9 | // let/const hoisting with Temporal Dead Zone |
| 10 | console.log(b); // ReferenceError: Cannot access 'b' before initialization |
| 11 | let b = 10; |
| 12 | |
| 13 | console.log(c); // ReferenceError |
| 14 | const c = 15; |
| 15 | |
| 16 | // Function declarations are fully hoisted |
| 17 | sayHello(); // "Hello!" — function is fully hoisted |
| 18 | function sayHello() { |
| 19 | console.log("Hello!"); |
| 20 | } |
| 21 | |
| 22 | // Function expressions are NOT hoisted (only the var/let/const) |
| 23 | sayGoodbye(); // TypeError: sayGoodbye is not a function |
| 24 | var sayGoodbye = function() { |
| 25 | console.log("Goodbye!"); |
| 26 | }; |
| 27 | |
| 28 | // Arrow function expressions — also not hoisted |
| 29 | greet(); // ReferenceError |
| 30 | const greet = () => console.log("Hi"); |
warning
The Temporal Dead Zone is the period between entering a scope and the actual declaration of a let or const variable. During this time, the variable exists in the scope but cannot be accessed — any attempt throws a ReferenceError. The TDZ is a deliberate design improvement over var's silent undefined behavior, catching potential bugs at runtime.
| 1 | // TDZ demonstration |
| 2 | { |
| 3 | // Enter block — TDZ starts for 'x' |
| 4 | // Any reference to 'x' here throws ReferenceError |
| 5 | |
| 6 | function logX() { |
| 7 | console.log(x); // ReferenceError if called before declaration |
| 8 | } |
| 9 | |
| 10 | // TDZ ends here: |
| 11 | let x = 42; |
| 12 | // Safe to use 'x' after this point |
| 13 | console.log(x); // 42 |
| 14 | } |
| 15 | |
| 16 | // TDZ with typeof — unexpected behavior |
| 17 | console.log(typeof undeclaredVar); // "undefined" (not an error) |
| 18 | console.log(typeof tdzVar); // ReferenceError! |
| 19 | let tdzVar = "hello"; |
| 20 | |
| 21 | // TDZ in conditional — the variable IS in scope but uninitialized |
| 22 | if (true) { |
| 23 | // 'val' is in scope here (TDZ) |
| 24 | function test() { |
| 25 | console.log(val); // ReferenceError if called |
| 26 | } |
| 27 | // test(); // Would throw |
| 28 | let val = "defined"; |
| 29 | test(); // Would work here (after TDZ ends) |
| 30 | } |
| 31 | |
| 32 | // TDZ with default parameters |
| 33 | function foo(a = b, b = 5) { |
| 34 | return a; |
| 35 | } |
| 36 | foo(); // ReferenceError: Cannot access 'b' before initialization |
| 37 | // TDZ applies to the parameter scope — 'b' is not yet initialized |
pro tip
JavaScript uses lexical scoping (also called static scoping), meaning the scope of a variable is determined by its location in the source code at author-time, not at runtime. When a variable is referenced, JavaScript traverses the scope chain — starting from the innermost scope and moving outward — until it finds the variable or reaches the global scope and throws a ReferenceError.
| 1 | // Lexical scope is determined by where code is written |
| 2 | const global = "global"; |
| 3 | |
| 4 | function outer() { |
| 5 | const outerVar = "outer"; |
| 6 | |
| 7 | function inner() { |
| 8 | const innerVar = "inner"; |
| 9 | |
| 10 | // Scope chain: inner → outer → global |
| 11 | console.log(innerVar); // "inner" — found in current scope |
| 12 | console.log(outerVar); // "outer" — found in outer's scope |
| 13 | console.log(global); // "global" — found in global scope |
| 14 | } |
| 15 | |
| 16 | inner(); |
| 17 | } |
| 18 | |
| 19 | outer(); |
| 20 | |
| 21 | // The scope chain is fixed at author-time, not call-time |
| 22 | const x = "global x"; |
| 23 | |
| 24 | function test() { |
| 25 | console.log(x); // "global x" — accesses the global 'x' |
| 26 | } |
| 27 | |
| 28 | function run() { |
| 29 | const x = "local x"; |
| 30 | test(); // Still prints "global x" — lexical scope! |
| 31 | } |
| 32 | |
| 33 | run(); |
| 34 | |
| 35 | // Variable shadowing — inner scope "shadows" outer |
| 36 | const value = "outer"; |
| 37 | function shadow() { |
| 38 | const value = "inner"; // shadows the outer 'value' |
| 39 | console.log(value); // "inner" |
| 40 | } |
| 41 | shadow(); |
| 42 | console.log(value); // "outer" — outer is unchanged |
| Scope Level | Where Created | Accessible From |
|---|---|---|
| Global | Outside any function or block | Everywhere in the program |
| Module | Top-level of ES module | Within the module (not leaked to global) |
| Function | Inside a function body | Inside the function and nested scopes |
| Block | Inside { } (if, for, while, etc.) | Within that block only (let/const) |
best practice
An Immediately Invoked Function Expression (IIFE, pronounced "iffy") is a function that is defined and executed immediately. IIFEs create a new function scope, which was the primary mechanism for creating private variables and avoiding global scope pollution before let and const introduced block scope. They remain useful in certain patterns.
| 1 | // Basic IIFE syntax |
| 2 | (function() { |
| 3 | const privateVar = "This is private"; |
| 4 | console.log("IIFE executed!"); |
| 5 | })(); |
| 6 | // privateVar is not accessible here |
| 7 | |
| 8 | // IIFE with parameters |
| 9 | (function(name, age) { |
| 10 | console.log(`${name} is ${age} years old`); |
| 11 | })("Alice", 30); |
| 12 | |
| 13 | // IIFE with return value |
| 14 | const result = (function(a, b) { |
| 15 | return a + b; |
| 16 | })(5, 3); |
| 17 | console.log(result); // 8 |
| 18 | |
| 19 | // IIFE for creating private state (module pattern) |
| 20 | const counter = (function() { |
| 21 | let count = 0; // private variable |
| 22 | |
| 23 | return { |
| 24 | increment() { return ++count; }, |
| 25 | decrement() { return --count; }, |
| 26 | getCount() { return count; }, |
| 27 | }; |
| 28 | })(); |
| 29 | |
| 30 | console.log(counter.increment()); // 1 |
| 31 | console.log(counter.increment()); // 2 |
| 32 | console.log(counter.getCount()); // 2 |
| 33 | console.log(counter.count); // undefined — private! |
| 34 | |
| 35 | // IIFE with arrow function |
| 36 | (() => { |
| 37 | const msg = "Arrow IIFE"; |
| 38 | console.log(msg); |
| 39 | })(); |
| 40 | |
| 41 | // IIFE for loop capture (pre-ES6 pattern — now use 'let') |
| 42 | for (var i = 0; i < 3; i++) { |
| 43 | (function(index) { |
| 44 | setTimeout(() => console.log(index), 100); |
| 45 | })(i); |
| 46 | } |
| 47 | // Without IIFE: would log 3, 3, 3 |
| 48 | // With IIFE: logs 0, 1, 2 |
pro tip
Consistent naming conventions make code more readable and maintainable. JavaScript does not enforce naming rules (beyond valid identifier requirements), but the community follows well-established conventions.
| Convention | Pattern | Examples | Used For |
|---|---|---|---|
| camelCase | lowercaseFirstWord | userName, fetchData, isActive | Variables, functions, methods, parameters |
| PascalCase | EachWordCapitalized | UserProfile, ApiClient, AppConfig | Classes, constructors, React components, types |
| UPPER_SNAKE_CASE | ALL_CAPS_UNDERSCORES | MAX_RETRIES, API_BASE_URL, DEFAULT_TIMEOUT | True constants (compile-time), environment variables |
| _underscorePrefix | _leadingUnderscore | _privateField, _internalFn, _cache | Private/internal (convention only, no enforcement) |
| $dollarPrefix | $leadingDollar | $el, $container, $btn | DOM element references (jQuery, or convention) |
| 1 | // Valid identifiers — letters, digits, $, _ (cannot start with digit) |
| 2 | const validName = "ok"; |
| 3 | const $dollar = "also ok"; |
| 4 | const _underscore = "fine"; |
| 5 | const camelCase = "standard"; |
| 6 | const PascalCase = "classes and components"; |
| 7 | const UPPER_SNAKE = "constants"; |
| 8 | |
| 9 | // Invalid identifiers |
| 10 | // const 123num = "no"; // SyntaxError: starts with digit |
| 11 | // const my-var = "no"; // SyntaxError: hyphen not allowed |
| 12 | // const const = "no"; // SyntaxError: reserved keyword |
| 13 | |
| 14 | // Reserved keywords cannot be used as identifiers |
| 15 | // await, break, case, catch, class, const, continue, debugger |
| 16 | // default, delete, do, else, enum, export, extends, false |
| 17 | // finally, for, function, if, import, in, instanceof, let |
| 18 | // new, null, of, return, super, switch, this, throw, true |
| 19 | // try, typeof, var, void, while, with, yield |
| 20 | |
| 21 | // Meaningful naming examples |
| 22 | // Bad: |
| 23 | const d = new Date(); |
| 24 | const u = "Alice"; |
| 25 | const f = (a, b) => a + b; |
| 26 | |
| 27 | // Good: |
| 28 | const currentDate = new Date(); |
| 29 | const userName = "Alice"; |
| 30 | const sum = (a, b) => a + b; |
| 31 | |
| 32 | // Boolean naming — use is/has/should prefixes |
| 33 | const isActive = true; |
| 34 | const hasPermission = false; |
| 35 | const shouldUpdate = true; |
| 36 | const isLoading = false; |
| 37 | |
| 38 | // Array naming — plural or List/Array suffix |
| 39 | const users = ["Alice", "Bob"]; |
| 40 | const fruitList = ["apple", "banana"]; |
| 41 | const itemArray = [1, 2, 3]; |
best practice
Essential Guidelines
Common Pitfalls
✗ Accidentally Creating Globals
function set() { total = 0; } // total becomes global!Without strict mode, assignment to an undeclared variable creates a global. Always use "use strict" or ES modules.
✗ Confusing var Hoisting
console.log(x); var x = 5; // undefined, not errorUse let and const so hoisting violations throw errors instead of silently returning undefined.
✗ Block Scope Confusion with var
if (true) { var x = 5; } console.log(x); // 5 — var ignores blocksUse let or const when you want block-scoped variables.
✗ Const ≠ Immutable
const arr = [1]; arr.push(2); // Works — const prevents reassignment, not mutationconst prevents reassignment of the binding, not mutation of the value. Use Object.freeze() for shallow immutability.
danger