Hoisting is one of those JavaScript behaviors that happens automatically, often without us realizing it.
In simple terms: before your code runs, JavaScript moves certain declarations to the top of their scope (a scope can be global, function, or block).
That’s why you can sometimes use a function or variable before it’s written in the code.
Declarations are any declarations of functions, variables, classes or imports.
Types of Hoisting
Use a variable’s value before the code line where the variable is declared in the scope
Function Declarations → Fully hoisted (you can call them before they appear).
Be able to reference a variable before the code line where the variable is declared, although its value isn’t hoisted and remains undefined until the code line is executed in the scope
var Variables → Declaration hoisted, value set to undefined until assignment.
The scope is aware of the variables existence but the value is not hoisted
let / const Variables & Classes → Declaration hoisted, but not initialized (they live in the temporal dead zone until their line runs).
There is a difference in how the scope works before and after the declaration (i.e. the whole scope needs to work in sync because of the declaration)
Imports → Always hoisted to the top of the module before code runs.
Value Hoisting
From the 4 different types of function we can define (Function Declarations, Function Expressions, IIFE and Arrow Functions), only Function Declarations are hoisted as per Value Hoisting
console.log(foo("cat")) // "cat"console.log(bar("dog") // ReferenceError: Cannot access 'bar' before initialization// Function Declarationfunction foo(a) {return a}// Function Expressionsconst bar = function (a) {return n}// Immediately Invoked Function Expressions (IIFE)// This function is executed where its declared in the code(function (a) {return a})("fizz")
console.log(buzz("cat")) // ReferenceError: Cannot access 'buzz' before initialization// Arrow Functionconst buzz = (a) => {return a}
So, remember:
Declaration Hoisting
console.log(a) // undefinedvar a = "some value"console.log(a) // "some value"function foo() {console.log(a) // undefinedvar a = "scoped value"console.log(a) // "scoped value"}foo()
The above example is interpreted as:
var aconsole.log(a) // undefineda = "some value"console.log(a) // "some value"function foo() {var aconsole.log(a) // undefineda = "scoped value"console.log(a) // "scoped value"}foo()
As illustrated in the codes above, var declarations are hoisted up before execution in the scope. This means if you access a variable before its declared, the value of that variable is always undefined (only its declaration and default initialization undefined is hoisted, not its value assignment)
A point to note here is that although the global scope does have a assigned a value, a is still undefined in the function scope because the local declaration of a inside the function scope takes precedence over (shadows) global scope (<link to understanding closures here>). If the function scope hadn’t redeclared a , a would not have been undefined
console.log(a) // undefinedvar a = "some value"console.log(a) // "some value"var b = "once"function foo() {console.log(a) // undefinedconsole.log(b) // "once" <- not undefined as the function scope does not redeclare bvar a = "scoped value"console.log(a) // "scoped value"}foo()
For all the above reasons, as a best practice to follow, we should always try to place var variables near to the top of the function as possible (or the top of the file depending on the scope). More importantly, avoid var in modern code and use let and const for predictable scoping
Declaration Hoisted, Value not assigned
Unlike var, variables declared with let and const are not immediately initialized. Some also consider these as non-hoisting because we run into a concept called Temporal dead zone as accessing values of let , const and classes declarations before their declaration code line is strictly forbidden.
const a = "foo"console.log(a) // "foo"console.log(b) // ReferenceErrorconst b = "bar"
Although considered non-hoisting, the below code snippet suggests some for of hoisting does occur as declaring x in the inner scope causes console.log(x) to produce a ReferenceError whereas it should be able to read the value of x from the outer scope. Inner scope declaration taints the outer scope declaration due to hoisting
const x = "foo"{console.log(x) // ReferenceErrorconst x = "bar"}
Another point to consider is that the below code snippet is not a form of hoisting
{var a = "foo";}console.log(a) // "foo"
Simply because var declarations are not scoped to blocks, var a is hoisted to the top of the outer scope, a = "foo" is executed from the inner block and then the initialized a is available to console.log . (<read more about scopes here>)
Declaration causes the scope to behave different
Import declarations are hoisted to the top of the module regardless of where they are declared. The values of these import declarations are available even before the place that declares them and the imported module’s side effects are produced before the rest of the module code starts executing.
const foo = new Foo()foo.create()import { Foo } from './modules/foo.js'foo.createBar()
Interpreter would rearrange this as
// Step 1: Module loading phase// All static imports are resolved *before* any code runs.import { Foo } from './modules/foo.js'// Step 2: Execution phaseconst foo = new Foo()foo.create()foo.createBar()
A point to remember is that we are talking about Static Imports. Another type of import is called Dynamic Imports and these declarations act more like a function-like expression that returns a Promise . Dynamic Imports follow the rules of let , const or var accordingly to their declaration type.
// Static Importimport { Foo } from './modules/foo.js'function bar() {// Dynamic Import - Not hoisted as static import doconst bImport = await import("b").then(x => x)}
Summary
👉 As a rule of thumb:
Write your variables and imports at the top, and use let/const over var. This avoids confusion and keeps code predictable.