Hoisting is what lets you call a function before its declaration but not read a let variable before its line runs. Both behaviors come from the same two-phase process the engine runs before executing a single line of your code.
hoistingtdztemporal-dead-zonevarletfundamentals
Knowledge Check
6 questions · pass at 70%
0/6
easypredict output
1. What does this log?
code
console.log(typeof greet);
function greet() {}
Interview Questions
3 questions
Cheatsheet
Download-ready reference
Cheatsheet
Two phases per scope: CREATION (set up bindings) → EXECUTION (run code top to bottom)
function foo() {} → hoisted WHOLE (name + value) — callable before its line
var x → hoisted, initialized to undefined
let / const / class → hoisted, but in TDZ until their declaration line runs
Access before declaration:
var → undefined, no error
let/const → ReferenceError (TDZ)
function decl → works normally
typeof safety:
typeof neverDeclared → "undefined" (safe)
typeof letInTDZ → ReferenceError (NOT safe)
Function expression (var/let fn = () => {}) follows its VARIABLE's
hoisting rules, not the function-declaration rules.
Rule of thumb: write code in the order it executes. Never rely on hoisting.
The beginner framing: "hoisting" is the (slightly misleading) name for the fact that declarations seem to move to the top of their scope before any code runs. It's why this works:
sayHi(); // "Hi!" — works, even though sayHi is called before its declarationfunction sayHi() { console.log("Hi!");}
The precise mental model: nothing actually moves. Before executing any code in a scope, the JavaScript engine runs a creation phase: it scans the scope for every var, function, let, const, and class declaration and registers a binding for each one before the execution phase (running your code top to bottom) begins. What differs is how each binding is initialized during that creation phase — and that difference is the entire story of hoisting.
The engine-level view — three distinct behaviors, not one:
Declaration
Creation phase
Result of using it before its line
function foo() {}
Binding created and fully assigned the function
Works completely
var x
Binding created, initialized to undefined
Reads as undefined, no error
let / const / class
Function declarations are hoisted "whole" — both the name and the value — which is why you can call a function before the line that defines it. var is hoisted "half-way" — the name exists, but its value doesn't arrive until the assignment line actually executes. let/const are hoisted too (they must be, for scoping to work at all — see the shadowing example in Scope) but stay in a locked state called the Temporal Dead Zone (TDZ) until their declaration line runs.
var hoisting vs. let's temporal dead zone
step 1 / 6
console.log(typeof x); // ?
console.log(typeof z); // ?
var x = 1;
console.log(typeof y); // ?
let y = 2;
Global (creation phase)
x: undefined (var — hoisted + pre-initialized)
y: «TDZ» (let — hoisted, not initialized)
z: — not declared anywhere —
Before any line runs, the engine scans for declarations. var x is hoisted AND pre-initialized to undefined. let y is hoisted but left in the Temporal Dead Zone. z isn't declared at all, so it gets no binding.
Before let/const, var's "declared but undefined" behavior was a steady source of bugs — code that silently read undefined instead of failing loudly when a variable was used too early. The TDZ is a deliberate design choice: fail fast with a clear ReferenceError instead of silently propagating undefined.
console.log(count); // undefined — var, no errorvar count = 5;console.log(total); // ReferenceError — TDZlet total = 5;
Only function declarations get the "hoisted whole" treatment. Function expressions — including ones assigned to var, let, or const — follow their variable's hoisting rules instead, not the function's.
sayA(); // ✅ "A" — declaration, hoisted wholefunction sayA() { console.log("A"); }sayB(); // ❌ TypeError: sayB is not a function — `sayB` is `undefined` at this point (var hoisting)var sayB = function () { console.log("B"); };sayC(); // ❌ ReferenceError — `sayC` is in the TDZ (let hoisting)let sayC = function () { console.log("C"); };
console.log(a);console.log(b);console.log(c());var a = 1;let b = 2;function c() { return "c!";}
Solution
undefinedReferenceError: Cannot access 'b' before initialization
Execution never reaches console.log(c()) — it throws on line 2. If you comment out the b line: a logs undefined (var, hoisted + pre-initialized), and c() logs "c!" (function declarations are hoisted whole, so calling it before its source position works fine).
Write a tiny static analyzer that reports, for a given list of statements, which variables would be accessible (and with what initial value) at the very top of a scope — a miniature model of what the engine's creation phase does:
function simulateHoisting(declarations) { const bindings = {}; // Pass 1: function declarations — hoisted whole for (const d of declarations) { if (d.kind === "function") bindings[d.name] = "function"; } // Pass 2: var — hoisted, initialized to undefined (don't clobber a function of the same name) for (const d of declarations) { if (d.kind
This mirrors the real two-pass process closely enough to build real intuition: function declarations always win the "already usable" slot, var gets a placeholder value, and let/const/class get a binding that exists but isn't safe to touch yet.
Hoisting rarely causes bugs inside components — the classic gotcha is at module scope: declaring a component with a const arrow function and referencing it (e.g., in a route config or another component) above its declaration line throws the same TDZ error as any other let/const.
// ❌ ReferenceError — Header is in the TDZ hereconst routes = [{ path: "/", component: Header }];const Header = () => <h1>Home</h1>;
// ✅ function declarations are hoisted whole — order doesn't matterfunction Header() { return <h1>Home</h1>; }const routes = [{ path: "/", component: Header }];
This is one of the few remaining practical arguments for function Component() {} over const Component = () => {} at the top level of a file — declaration order stops mattering.
typeof only protects you against variables that don't exist at all. It offers zero protection against a let/const that's hoisted but not yet initialized — a very common source of confusion, since most people learn "typeof is always safe" from the undeclared-variable case and generalize incorrectly.
Declare functions and variables before use, in source order — don't rely on hoisting as a feature, even where it technically works. Code that reads top-to-bottom in execution order is easier to reason about.
Prefer function declarations for top-level, hoist-order-independent utilities; use const arrow functions for anything that's conceptually a value (callbacks, component props) where hoisting isn't relevant anyway.
Treat any TDZ ReferenceError as a signal to reorder your code, not to fall back to var — the error is doing you a favor by catching a real ordering bug early.
Hoisting itself has no runtime performance cost — the creation phase is part of how the engine already has to parse and set up a scope; you're not paying extra for it.
The TDZ has a negligible, effectively unmeasurable check cost. Don't avoid let/const for "hoisting performance" reasons — that tradeoff doesn't exist in any modern engine.
Binding created, not initialized — in the Temporal Dead Zone
ReferenceError
===
"var"
&&
!
(d.name
in
bindings)) bindings[d.name]
=
undefined
;
}
// Pass 3: let/const/class — hoisted but in the TDZ
for (const d of declarations) {
if (["let", "const", "class"].includes(d.kind)) bindings[d.name] = "<TDZ>";