Frontend Master

JavaScript Curriculum

Where Does a Variable Live?
+45 XP

Where Does a Variable Live?

medium
~25 min·45 XP

Two engineers on the Nexus team both declare a variable called count in their code. Neither one is wrong. The variables don't conflict. The program doesn't explode. JavaScript has a system for this — a set of rules that decides exactly where each variable exists and who can reach it. Understanding that system is the difference between writing code that works reliably and writing code that breaks in mysterious ways.

The invisible containers around your code

Every variable you declare lives somewhere. JavaScript divides a program into nested containers called scopes — and a variable can only be seen from inside the scope it was declared in, or from any scope nested inside that one.

The rule is one-directional: inner scopes can see outward, outer scopes cannot see inward.

js
const APP_NAME = "Nexus" // global — visible everywhere function greetUser() { const userId = 42 // function scope — only inside greetUser if (userId > 0) { const message = "welcome" // block scope — only inside this if block console.log(APP_NAME) // ✓ inner can see outer console.log(userId) // ✓ inner can see outer console.log(message) // ✓ in same scope } console.log(message) // ✗ ReferenceError — message is block-scoped } console.log(userId) // ✗ ReferenceError — userId is function-scoped

Click each variable below to see exactly which scopes can and cannot access it:

scope visualiser — click any variable
🌍 global scopeaccessible everywhere
⚙️ function scopefunction greetUser()
📦 block scopeif { } / for { }

Click a variable to see which scopes can access it.

🧠Scope is like nested rooms
Each scope is a room with one-way glass walls. You can see out through the glass into every parent room. But the rooms outside cannot see in. A variable declared in the inner room stays private to that room and its children.

The three scope levels

Global scope — the outermost container. Variables declared here are accessible from everywhere in your program. Use sparingly: global variables are shared state, and shared state is the source of many bugs.

Function scope — every function creates its own scope. Variables declared inside a function (var, let, or const) are invisible from outside that function.

js
function calculateTotal(price, qty) { const subtotal = price * qty // exists only here const tax = subtotal * 0.2 return subtotal + tax } console.log(subtotal) // ✗ ReferenceError — gone after function exits

Block scope — any pair of curly braces { } creates a block scope for let and const declarations. This includes if bodies, for loops, while loops, and plain { } blocks.

js
for (let i = 0; i < 3; i++) { const doubled = i * 2 // new const for every iteration console.log(doubled) // ✓ fine here } console.log(i) // ✗ ReferenceError — i was block-scoped to the loop console.log(doubled) // ✗ ReferenceError — same reason
⚠️var ignores block scope
var is only function-scoped, not block-scoped. A var declared inside an if block or for loop leaks out into the enclosing function. This is one of the main reasons let and const replaced var entirely in modern JavaScript.
js
if (true) { var leaked = "I escape" // var ignores the if block's scope let stayed = "I don't" // let is block-scoped } console.log(leaked) // "I escape" ← surprising and dangerous console.log(stayed) // ✗ ReferenceError ← expected, safe

Hoisting — what the engine does before running your code

JavaScript doesn't just run your code top to bottom. Before execution begins, the engine does a preparation pass — scanning for declarations and doing something different depending on which keyword you used.

Click each keyword and each line position to see exactly what happens:

hoisting — what happens before the declaration line

Line BEFORE declaration — result

undefined

Hoisted and initialised to undefined — no crash, but wrong value

what the JS engine does

JS hoists var declarations to the top of their function scope and sets them to undefined before any code runs. The assignment stays in place.

⚠️ Technically works but silently returns undefined — a common source of bugs. Never use var.

The summary:

  • var — hoisted and initialised to undefined. Accessing before declaration gives you undefined silently — a hidden bug waiting to happen.
  • let / const — hoisted but not initialised. The gap from the start of their scope to their declaration is the Temporal Dead Zone (TDZ). Any access in this zone throws a ReferenceError immediately — loud and obvious.

Loud errors are better than silent wrong values. This is why let and const are safer.


Closures — functions that remember

A closure is what happens when an inner function retains access to variables from its outer function's scope — even after the outer function has finished executing.

Step through the counter factory below. Pay close attention to count: it's declared inside makeCounter, but the returned inner function keeps a live reference to it forever.

closure demo — step through the counter factory
1function makeCounter() {
2let count = 0 // lives here, in makeCounter's scope
3return () => { count++; console.log(count) }
4}
5const increment = makeCounter()
6increment() // call it multiple times…

closed-over: count

not created yet

output log

nothing yet…

Start — factory function not yet called

js
function makeCounter() { let count = 0 // created once, lives in this scope return () => { // this function "closes over" count count++ console.log(count) } } const increment = makeCounter() // makeCounter is done — but count survives increment() // 1 increment() // 2 increment() // 3 ← count is still alive, still incrementing const incrementB = makeCounter() // brand new scope, brand new count incrementB() // 1 ← independent, starts from 0 increment() // 4 ← first counter unaffected

The key insight: every call to makeCounter creates a fresh scope with its own count. The two counters are completely independent — they don't share state.

🧠A closure is a backpack
When a function is created inside another function, it packs a backpack with everything it needs from the outer scope. When the outer function exits, those variables aren't garbage-collected — the inner function is still carrying them in its backpack. It can read and write them indefinitely.

Closures in real code

Closures aren't an exotic edge case — they're used constantly:

js
// 1. Memoisation — cache expensive results function memoize(fn) { const cache = {} // cache lives in the closure return (arg) => { if (cache[arg] !== undefined) return cache[arg] cache[arg] = fn(arg) return cache[arg] } } // 2. Configuration — pre-bake a setting into a function function makeMultiplier(factor) { return (n) => n * factor // factor is closed over } const double = makeMultiplier(2) const triple = makeMultiplier(3) double(5) // 10 triple(5) // 15 // 3. Event handlers in React — access component state via closure function Counter() { const [count, setCount] = useState(0) // handleClick closes over setCount — it can call it even though // handleClick is passed down to a child component const handleClick = () => setCount(count + 1) return <button onClick={handleClick}>{count}</button> }

Your challenge

Build a greeter factory — same structure as the counter factory, but instead of remembering a number it remembers a greeting string.

makeGreeter("Hello") should return a function. That function takes a name and returns "Hello, Alex!" (or whatever name is passed). makeGreeter("Hi") should return a completely independent function that says "Hi, ..." instead.

Two closures. Two independent backpacks. Same structure, different contents.

Challenge

Write a function called makeGreeter(greeting) that returns a new function. The returned function should accept a name argument and return the string greeting + \", \" + name + \"!\". Create two greeters — one with \"Hello\" and one with \"Hi\" — and call each with a name to prove they work independently.

scopelexical-scopeclosureshoistingtemporal-dead-zonevarletconst
Where Does a Variable Live? | Nexus Learn