Concept
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 declaration
function 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.
console.log(typeof x); // ?console.log(typeof z); // ?var x = 1;console.log(typeof y); // ?let y = 2;
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.
Why the TDZ exists
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 error
var count = 5;
console.log(total); // ReferenceError, TDZ
let total = 5;Function declarations vs. function expressions
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 whole
function 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"); };Try It
Predict the output, then check yourself.
console.log(a);
console.log(b);
console.log(c());
var a = 1;
let b = 2;
function c() {
return "c!";
}Solution
undefined
ReferenceError: Cannot access 'b' before initializationExecution 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).
Implement It Yourself
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 === "var" && !(d.name in bindings)) bindings[d.name] = undefined;
}
// Pass 3: let/const/class, hoisted but in the TDZ
for (const d of declarations) {
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.
In React
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 here
const routes = [{ path: "/", component: Header }];
const Header = () => <h1>Home</h1>;// ✅ function declarations are hoisted whole, order doesn't matter
function 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.
Common Mistakes
1. Assuming var hoisting means the value moves
console.log(status); // undefined, not "active", only the DECLARATION hoists
var status = "active";Only the binding is hoisted; the assignment happens exactly where it's written.
2. Expecting typeof to be TDZ-safe
typeof neverDeclared; // "undefined", safe
typeof inTdz; // ReferenceError!
let inTdz = 1;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.
3. Redeclaring var and expecting an error
var count = 1;
var count = 2; // totally legal, same binding, silently overwritten
console.log(count); // 2let/const throw SyntaxError: Identifier has already been declared for the same thing, another reason they're safer defaults.
Best Practices
- 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
constarrow functions for anything that's conceptually a value (callbacks, component props) where hoisting isn't relevant anyway. - Treat any TDZ
ReferenceErroras a signal to reorder your code, not to fall back tovar, the error is doing you a favor by catching a real ordering bug early.
Performance Tips
- 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/constfor "hoisting performance" reasons, that tradeoff doesn't exist in any modern engine.
