I still remember a production bug where a revenue dashboard showed 0 for the first minute after deploy. The data was fine. The rendering was fine. The bug was a single line: a var inside an if that shadowed a global value, then got read before assignment. The console showed undefined, and the numbers looked like they were missing. You can imagine the panic.
When I teach JavaScript scoping and hoisting, I start from that exact feeling: “Why is this value missing when I can see it right there?” The fix is simple once you see the rules, but the rules are not the ones you learned in C or Java. JavaScript creates scopes differently, and it prepares bindings before it runs any line of your code. That preparation is what we call hoisting.
I’ll walk you through how scope and hoisting actually behave in modern JavaScript, how var, let, and const differ, and why “undefined” appears even when you think it shouldn’t. I’ll also show patterns I use in real projects, mistakes I still watch for in reviews, and how 2026 tooling helps catch these issues before they ship.
Scopes you actually work with in 2026
If you learned “global and local scope,” you learned a useful starting model. In practice, you’ll run into more than two scopes in everyday work:
- Global scope: In a classic script running in a browser, this is
window. In Node.js, it’sglobal. In ES modules, top‑level variables are not attached towindoworglobal. - Module scope: Each ES module has its own top‑level scope. This is the default in 2026 because bundlers and browsers use modules by default.
- Function scope: Every function creates a new scope.
- Block scope:
letandconstcreate bindings that are limited to{ ... }blocks. - Catch scope:
catch (error)creates a block‑scoped binding for the error variable.
A simple analogy I use: think of scopes like locked rooms in an office building. A variable is a document. If you create it in the lobby (global), anyone can read it. If you create it in a conference room (function), only people inside can read it. If you create it in a phone booth (block), only someone inside that tiny booth can read it. Hoisting is the building’s policy of placing labeled folders in the rooms before employees arrive. The folders exist, but their contents may be empty.
Here’s a runnable example that shows global, function, and block scope together:
// Example: browser script (not module)
var appName = "MetricsBoard"; // global in script mode
function showStatus() {
const environment = "production"; // function scope
if (environment === "production") {
let banner = "LIVE"; // block scope
console.log(appName, environment, banner);
}
// console.log(banner); // ReferenceError: banner is not defined
}
showStatus();
In module code, the same top‑level var is not attached to window, which changes how global access works. This matters when you mix older scripts with modern modules in a single page.
Hoisting: what is created, when, and why undefined appears
Hoisting is not magic. JavaScript engines run your code in two broad phases for each execution context:
- Creation phase: The engine allocates memory for variables and function declarations, then sets up scope chains.
- Execution phase: The engine runs your code line by line and assigns values.
During the creation phase, var variables are created and initialized to undefined. Function declarations are created and fully initialized with the function body. let and const are created too, but they are left uninitialized until their declaration is evaluated. That “uninitialized” state is the Temporal Dead Zone (TDZ).
Here is a minimal example that shows the difference:
console.log(userName); // undefined
// console.log(accountId); // ReferenceError (TDZ)
// console.log(getStatus()); // "ok"
var userName = "alex";
let accountId = 42;
function getStatus() {
return "ok";
}
Why does this matter? Because undefined is a real value. Your code can keep running with it, which is exactly how silent bugs happen. I treat any unexpected undefined as a signal to check for hoisting or shadowing.
Let’s recreate a classic case:
function reportSales() {
console.log(total); // undefined
if (true) {
var total = 500;
}
console.log(total); // 500
}
reportSales();
Inside reportSales, the var total is hoisted to the top of the function, so the first console.log reads the local total, not any outer one. The local binding exists and is undefined until the assignment happens.
The var trap: function scope, shadowing, and surprising if‑blocks
Most real bugs with scope come from var in functions. When you declare with var, you get function scope, not block scope. That means if, for, and while blocks do not protect you from accidental shadowing.
Consider this example, which often surprises developers coming from block‑scoped languages:
var discount = 10;
function applyDiscount() {
if (discount > 5) {
var discount = 20; // same function scope, not a new block
}
console.log(discount);
}
applyDiscount(); // undefined
Why undefined? Because var discount is hoisted to the top of the function. The if block doesn’t create a scope, so discount inside the function shadows the outer discount. The local one starts as undefined and only gets a value if the if runs. If the condition is false, you still read undefined.
Here’s the same issue in the exact pattern I see in code reviews:
var totalUsers = 1200;
function showCount() {
if (totalUsers > 2000) {
var totalUsers = 3000;
}
console.log(totalUsers); // undefined
}
showCount();
You can avoid this by either using let/const or isolating a block with a function. But in 2026, you should almost never use var unless you’re patching legacy code.
let/const and the TDZ: safer defaults with real costs
let and const give you block scope, which matches how most people think about if and for. They still hoist, but the TDZ prevents early access before the declaration runs.
function buildReport() {
// console.log(reportTitle); // ReferenceError due to TDZ
const reportTitle = "Q4 Revenue";
if (reportTitle.includes("Q4")) {
let status = "final";
console.log(reportTitle, status);
}
// console.log(status); // ReferenceError: status is not defined
}
buildReport();
The TDZ is a safety net. It turns a silent undefined bug into a visible error. But it also means you can’t safely access a let or const before its declaration, even with typeof:
// console.log(typeof taskId); // ReferenceError (TDZ)
let taskId = "T-991";
This is one reason I encourage explicit ordering: declare your bindings at the top of a block, then use them. It makes the TDZ a non‑issue and makes intent obvious to anyone reading the code.
Here’s a clear Traditional vs Modern comparison. I recommend the Modern approach unless you’re in a very old codebase.
Scope Behavior
Typical Fix
—
—
var + IIFE) Function scope only
Wrap with IIFE
let/const) Block and function scope
Move declarations earlier
Functions, classes, and imports: different hoisting rules
Not all declarations are hoisted the same way. I see confusion most often with function expressions, class declarations, and module imports.
Function declarations
Function declarations are hoisted with their full body. You can call them before they appear in the file.
const result = calculateTax(1000);
console.log(result);
function calculateTax(amount) {
return amount * 0.2;
}
Function expressions
Function expressions are not initialized until the assignment runs. With var, the name is hoisted as undefined. With let or const, the TDZ applies.
// console.log(formatMoney(25)); // TypeError or ReferenceError depending on declaration
const formatMoney = function formatMoney(amount) {
return $${amount.toFixed(2)};
};
Arrow functions
Arrow functions behave like function expressions.
// console.log(isActive()); // ReferenceError due to TDZ
const isActive = () => true;
Class declarations
Class declarations are hoisted but not initialized, so they behave like let with a TDZ. This surprises people who expect class declarations to behave like function declarations.
// const user = new User(); // ReferenceError due to TDZ
class User {
constructor(name) {
this.name = name;
}
}
Imports
ES module imports are hoisted and live in their own scope. You can use an imported binding anywhere in the module. But you cannot import conditionally. If you need that, use dynamic import().
// module file
import { parse } from "./parser.js";
const data = parse("1,2,3");
The key is to know which declarations are safe to call early. Only function declarations offer that guarantee across the board.
Real‑world patterns and anti‑patterns
This is where I focus in code reviews. The difference between clean, predictable scoping and confusing scoping is usually one or two lines.
Patterns I use often
1) Top‑load declarations inside functions
function renderWidget(config) {
const theme = config.theme ?? "light";
const size = config.size ?? "md";
// use theme and size
return ${theme}-${size};
}
This keeps the TDZ from ever being a problem and makes the function easy to scan.
2) Block scope for temporary values
function getDisplayName(user) {
if (!user) return "Guest";
// block scoping keeps this temporary
{
const first = user.firstName ?? "";
const last = user.lastName ?? "";
return ${first} ${last}.trim();
}
}
3) Module scope for shared state
// module scope
const cache = new Map();
export function fetchProfile(id) {
if (cache.has(id)) return cache.get(id);
const profile = { id, name: "Sam" };
cache.set(id, profile);
return profile;
}
Anti‑patterns I still see
- Using
varinside loops and expecting block behavior. - Relying on implicit globals by forgetting
constorlet. - Shadowing in nested scopes without intent.
- Calling function expressions before declaration.
Here’s an example of a loop bug with var that leaks the last value:
const buttons = ["Buy", "Save", "Share"];
for (var i = 0; i < buttons.length; i++) {
setTimeout(() => {
console.log(Clicked ${buttons[i]}); // undefined for i === 3
}, 0);
}
Fix it with let to create a new binding per iteration:
const buttons = ["Buy", "Save", "Share"];
for (let i = 0; i < buttons.length; i++) {
setTimeout(() => {
console.log(Clicked ${buttons[i]}); // correct
}, 0);
}
When to use vs when not to use
Use function scope when you truly want one shared binding across the entire function, such as a memoized function or a cached result.
Do not use function scope to isolate a temporary value in a loop or an if block. Use let or const instead.
Use module scope for shared state that is safe to keep across calls in a single runtime instance.
Do not use module scope for per‑request values in a server, since the scope is shared across requests.
Performance and tooling reality checks
In terms of runtime performance, scoping rules are not the bottleneck in modern JavaScript engines. The difference between var and let is rarely measurable in real apps. When I do see a measurable effect, it’s usually in tight loops with large closures, and the change is often within a small single‑digit to low‑double‑digit millisecond range for large workloads. Most of the time, clarity and predictability matter far more.
What does matter in 2026 is tooling. I recommend:
- TypeScript for catching shadowing and unused variables at compile time.
- ESLint (or Biome if you prefer a single tool) with rules like
no‑use‑before‑defineandno‑shadow. - Editor hints that show TDZ and scope boundaries.
- AI‑assisted code review that flags risky
varusage or hoisted access. I’ve seen these tools catch scope bugs in seconds that would otherwise slip to production.
A practical lint rule setup can prevent most of the hoisting‑related bugs I see:
// eslint.config.js (flat config style)
export default [
{
rules: {
"no-shadow": "error",
"no-use-before-define": ["error", { functions: false, classes: true }],
"no-var": "error",
"prefer-const": "error"
}
}
];
Notice that I allow function declarations before use, but I forbid class use before declaration. That mirrors how hoisting works and keeps the rules intuitive.
A guided walk‑through of the classic examples
Now I’ll revisit the key examples you’ve likely seen, but with modern explanations and concrete reasoning.
Example 1: Global vs function scope
var value = 10;
function test() {
var value = 20;
}
test();
console.log(value); // 10
The value inside test is a different binding. It is function‑scoped, and it never escapes the function. The outer value stays 10.
Example 2: Read inside the function
var value = 10;
function test() {
var value = 20;
console.log(value); // 20
}
test();
Same idea, but now you’re reading the inner binding, so you see 20.
Example 3: The undefined surprise
var value = 10;
function test() {
if (value > 20) {
var value = 50;
}
console.log(value); // undefined
}
test();
Here’s what happens during the creation phase inside test:
- The engine creates a local
valuebinding withvar. - It sets it to
undefined. - The
ifcondition uses the localvalue, which isundefined. undefined > 20is false, so the assignment never runs.- The final
console.logreads the localvalue, stillundefined.
If you rewrite it with let, you get a different result and clearer intent:
const value = 10;
function test() {
if (value > 20) {
let value = 50; // new block binding
console.log(value); // 50 if reached
}
console.log(value); // 10
}
test();
Common mistakes and how I avoid them
- Mistake: Shadowing outer values unintentionally
– Fix: Rename inner bindings to show intent (localTotal, cachedUser).
- Mistake: Reading
varbefore assignment
– Fix: Use let/const and move declarations to the top of the block.
- Mistake: Relying on implicit globals
– Fix: Enable strict mode or use modules so accidental globals throw errors.
- Mistake: Confusing function declarations with expressions
– Fix: Decide whether you need hoisting; if not, use const and define before use.
How scope works in nested functions and closures
If hoisting explains when bindings exist, closures explain how long they live. I find it easier to teach these together, because many “scoping” bugs are really “closure” bugs with an unexpected binding.
function makeCounter() {
let count = 0;
return function increment() {
count += 1;
return count;
};
}
const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
Here, count lives inside the scope of makeCounter, and the inner function keeps a reference to it even after makeCounter returns. That’s a closure.
Now watch what happens when var sneaks in:
function buildHandlers() {
var handlers = [];
for (var i = 0; i < 3; i++) {
handlers.push(function () {
return i;
});
}
return handlers;
}
const [h1, h2, h3] = buildHandlers();
console.log(h1(), h2(), h3()); // 3 3 3
All three functions close over the same i binding, because var gives you one function‑scoped variable. If you want a separate binding per iteration, use let:
function buildHandlers() {
const handlers = [];
for (let i = 0; i < 3; i++) {
handlers.push(function () {
return i;
});
}
return handlers;
}
const [h1, h2, h3] = buildHandlers();
console.log(h1(), h2(), h3()); // 0 1 2
I have yet to find a team that intends the var behavior in 2026. If you need one shared binding, say it explicitly in a comment so the next person doesn’t “fix” it with let.
Scope and hoisting in async code
Async functions, timers, and promises make scope bugs more visible because the timing feels disconnected from the code order. The rules are the same, but the mental model gets harder.
A typical mistake looks like this:
let status = "idle";
async function loadProfile(id) {
status = "loading";
const response = await fetch(/api/profile/${id});
if (!response.ok) {
status = "error";
return null;
}
status = "ready";
return await response.json();
}
This is fine as‑is, but consider what happens if you refactor with a block and reuse the name:
let status = "idle";
async function loadProfile(id) {
status = "loading";
if (true) {
let status = "shadow"; // local to this block
// ...maybe a debug log that uses status
}
status = "ready";
}
The inner status doesn’t affect the outer status, which is good if you intended it. But if you expected to update the outer variable, you have a subtle bug. In async flows, I try to avoid shadowing altogether, because later refactors can move code across await boundaries and make the shadowing invisible.
A practical rule I follow: don’t reuse names across async boundaries unless the scopes are intentionally isolated. The clarity is worth the extra name.
Parameter scope, default values, and TDZ gotchas
Function parameters create their own scope. Default parameters are evaluated in that scope, and they can access earlier parameters but not later ones. This can produce unexpected TDZ‑like errors.
function createUser(name = "Guest", isAdmin = role === "admin", role = "user") {
return { name, role, isAdmin };
}
// ReferenceError: Cannot access ‘role‘ before initialization
The default value for isAdmin tries to read role, but role isn’t initialized yet. Swap the order and it works:
function createUser(name = "Guest", role = "user", isAdmin = role === "admin") {
return { name, role, isAdmin };
}
This is one of those “once a year” bugs that still costs an afternoon. I now explicitly order parameters so defaults only reference earlier params.
Block scope in try/catch and error handling
catch creates a block‑scoped binding for the error variable. That means you can safely use the same name elsewhere without collisions, but it also means the error value is not available outside the catch block.
try {
JSON.parse("{");
} catch (error) {
console.error("Parse failed", error.message);
}
// console.log(error); // ReferenceError
If you need the error outside, lift it intentionally:
let parseError = null;
try {
JSON.parse("{");
} catch (error) {
parseError = error;
}
if (parseError) {
console.warn("Retry or fallback", parseError.message);
}
The key is to be explicit about which values live in which scopes. Relying on “I can still see it down here” is exactly how scope bugs happen.
Shadowing: when it’s okay and when it’s not
Shadowing is not always wrong. In fact, it’s common and sometimes clean. But it should be intentional and short‑lived.
Okay example (intentional, short‑lived):
function normalizePrice(price) {
if (price == null) return 0;
const priceNumber = Number(price);
if (Number.isNaN(priceNumber)) return 0;
return priceNumber;
}
Here I intentionally avoid shadowing by giving the transformed value a new name. That’s my default.
Shadowing that I allow sometimes:
function parseQuery(query) {
if (!query) return {};
{
const query = query.trim();
return Object.fromEntries(new URLSearchParams(query));
}
}
This is controversial. I allow it in tiny blocks when the transformed value replaces the original and I immediately return. If the block grows, I rename it.
Shadowing that I avoid:
let total = 0;
function addToTotal(amount) {
if (amount > 100) {
let total = amount + 10; // looks like it updates outer total, but it doesn’t
return total;
}
total += amount; // now you have two different totals
}
This is a maintenance trap. Someone reading quickly assumes the inner total updates the outer. It doesn’t. I would rename the inner binding or refactor the logic to avoid the shadowing entirely.
Hoisting in classes, methods, and private fields
Class declarations are hoisted into a TDZ, so using them before declaration throws. But within the class, method definitions are effectively hoisted because the class body is evaluated as a whole.
class Service {
fetch() {
return this.#buildUrl();
}
#buildUrl() {
return "https://api.example.com";
}
}
Private fields and methods (#) are lexically scoped to the class body. They do not exist outside the class. If you try to access them before the class definition runs, you’ll still get a TDZ error because the class binding isn’t initialized yet.
I mention this because it’s easy to assume “methods are hoisted like functions.” They aren’t hoisted in the file, only within the class after the class is evaluated. The hoisting happens at the class level, not the method level.
Modules, top‑level await, and scope boundaries
Modules introduce a natural boundary. Top‑level variables in a module are not globals, and they are not attached to window or global. That’s good for encapsulation, but it can break older scripts that expect globals to exist.
Consider this pattern when mixing scripts and modules:
If legacy.js expects to read a global that modern.js defines, it will fail. The scope boundaries are different. I’ve seen this in production when teams move one file to modules but leave others as scripts.
In modules, you should always export and import instead of relying on globals. If you must expose something to legacy code, do it deliberately:
// modern.js (module)
export function initApp() {}
// explicit bridge for legacy access
globalThis.initApp = initApp;
That’s one of the few times I recommend attaching to globalThis in 2026.
Deeper practical scenarios: real bugs and fixes
Here are scenarios I’ve seen in the wild, with fixes you can apply immediately.
Scenario 1: A config value disappears after a refactor
const config = { mode: "safe" };
function bootstrap() {
if (config.mode === "safe") {
var config = { mode: "fast" };
}
console.log(config.mode); // TypeError: Cannot read properties of undefined
}
bootstrap();
Why it happens: var config hoists to the top of bootstrap, so the function’s config is undefined. The if condition tries to read config.mode from undefined.
Fix: Use let or rename the inner binding.
const config = { mode: "safe" };
function bootstrap() {
if (config.mode === "safe") {
const localConfig = { mode: "fast" };
console.log(localConfig.mode);
}
console.log(config.mode); // safe
}
Scenario 2: “Works in dev, fails in prod” due to bundling
In dev, your code might run as a single script with globals. In prod, bundlers might wrap modules and change scope boundaries. A var at the top level in a module isn’t global anymore, so older code that expects a global fails.
Fix: Move shared state into an explicit module and import it. If you must expose a global, do it intentionally via globalThis.
Scenario 3: A “used before defined” lint error that feels wrong
You write:
const format = (value) => value.toUpperCase();
console.log(format("ok"));
But the lint rule no-use-before-define flags it because your config is too strict. Adjust the rule to allow function declarations but not function expressions, which mirrors hoisting behavior. This keeps intent aligned with runtime behavior.
The mechanics: how engines build the scope chain
If you want a deeper mental model, here’s the simplified internal picture I use:
- Every time a function is invoked, the engine creates an execution context.
- The context has a Lexical Environment and a Variable Environment.
let,const, andclasslive in the Lexical Environment;varlives in the Variable Environment.- The Lexical Environment enforces the TDZ; the Variable Environment does not.
This is why var behaves differently from let/const. It’s not just “old vs new.” They’re stored in different internal records, and the engine initializes them differently.
You don’t need to memorize this, but it helps explain why certain behaviors feel “inconsistent.” They’re not inconsistent; they’re rooted in different internal rules.
Edge cases that still trip people up
Here are a few that I keep in my mental checklist.
1) typeof and TDZ
// console.log(typeof demo); // ReferenceError in TDZ
let demo = 1;
This feels wrong because typeof is often described as “safe.” It is only safe for undeclared variables, not for variables in the TDZ.
2) for...of and for...in with const
const items = ["a", "b", "c"];
for (const item of items) {
console.log(item);
}
This is valid because const item is a new binding per iteration. That’s a subtle but important point. const doesn’t mean “can’t change across iterations.” It means each iteration gets a new binding that isn’t reassigned within that iteration.
3) Redeclaration errors
let value = 1;
// let value = 2; // SyntaxError: already declared
var legacy = 1;
var legacy = 2; // allowed
let and const enforce redeclaration rules in the same scope. That’s a major safety upgrade from var.
4) Function declarations inside blocks
In modern JavaScript (in strict mode and modules), function declarations inside blocks are block‑scoped. In older environments, they could be function‑scoped. In 2026, assume block‑scoped unless you’re maintaining very old code.
if (true) {
function run() {
return "ok";
}
}
// In modern JS: run is not defined here
If you need a function that’s available outside a block, declare it in the outer scope and assign inside if needed.
Practical decision guide: which declaration should I use?
I keep a simple decision tree in my head:
- Is the value meant to change?
– No → use const.
– Yes → use let.
- Is it legacy code that requires
var?
– Only then use var.
- Do I need a function that can be called before definition?
– Use a function declaration.
- Do I need a function that must not be used before definition?
– Use a function expression assigned to const.
That’s it. If the codebase follows these rules consistently, I rarely see hoisting bugs.
Comparison table: declarations at a glance
Scope
Initialized at Hoist
Can Re‑declare
—
—
—
var Function
undefined
Yes
let Block
No
No
const Block
No
No
function decl Block/Function
Function body
N/A
class decl Block
No
No
I treat this table as a quick reference in reviews. It’s amazing how many disputes resolve just by mapping an issue to one row.
Tooling in 2026: lint, types, and automation
Here’s how I set up a typical modern project to minimize scope‑related bugs:
- TypeScript with
noImplicitAnyandstrict
– This helps surface accidental globals and unused variables.
- ESLint or Biome with scope‑focused rules
– no-shadow, no-var, prefer-const, no-use-before-define, no-undef.
- Editor integration
– I want real‑time highlights for shadowed names and unused variables. Most editors can do this with the right TypeScript/ESLint integration.
- AI-assisted review
– I use it as a second pair of eyes for hoisting and shadowing. It’s especially helpful in large refactors where scope boundaries shift.
These tools don’t replace understanding. They amplify it. The better your mental model, the more useful the warnings become.
Production considerations: debugging and monitoring scope issues
Scope bugs often show up as undefined values, ReferenceErrors, or inconsistent behavior between dev and prod. When I debug them, I follow a short checklist:
- Check hoisting: Is the variable declared with
varin the same function? - Check shadowing: Is there an inner
letorconstwith the same name? - Check modules: Is the code running as a module or a script?
- Check async boundaries: Did the refactor move the declaration below an
awaitor callback? - Check bundler output: Did tree‑shaking or module wrapping change scope boundaries?
For monitoring, I like to capture ReferenceError logs with stack traces, because TDZ errors often point directly to the line that needs reordering.
Alternative approaches and patterns
Sometimes, avoiding hoisting issues is about changing your structure rather than your declarations.
1) Use object literals instead of many top‑level vars
const app = {
name: "MetricsBoard",
version: "2.3.1",
init() {
console.log(this.name, this.version);
}
};
app.init();
This reduces the chance of name collisions in module or global scope.
2) Use function factories for controlled scope
function createStore(initial) {
let state = initial;
return {
get() {
return state;
},
set(next) {
state = next;
}
};
}
const store = createStore({ count: 0 });
The internal state is safely scoped, and there’s no temptation to reach for globals.
3) Use explicit namespaces in legacy code
If you must operate in the global scope for older scripts, create a namespace object to avoid collisions:
window.App = window.App || {};
window.App.utils = {
formatMoney(amount) {
return $${amount.toFixed(2)};
}
};
This is not my first choice in 2026, but it’s a clean pattern for legacy environments.
A practical “before and after” refactor
Here’s a realistic snippet I encountered during a refactor:
var report = null;
function buildReport(data) {
if (!data) return null;
if (data.type === "summary") {
var report = createSummary(data);
}
return report;
}
Sometimes this returned undefined because data.type was not "summary". The hoisted var report shadowed the outer report, so the function returned the inner report, which was undefined.
Refactored:
let report = null;
function buildReport(data) {
if (!data) return null;
if (data.type === "summary") {
const summary = createSummary(data);
return summary;
}
return report;
}
Now the scopes are explicit, the return paths are obvious, and the hoisting trap is gone.
Mini‑checklist for code reviews
When I review JavaScript, I silently run this checklist:
- Is
varused? If yes, is it required? - Are there any
let/constdeclarations below first use? - Is any variable shadowed in a way that could confuse readers?
- Are function expressions called before they are defined?
- Are module boundaries respected (no accidental globals)?
If I can answer all of those quickly, the code usually behaves as expected.
Why this still matters in 2026
It’s tempting to think modern tooling makes scope bugs irrelevant. It doesn’t. The mistakes are less frequent, but they still show up during refactors, migrations, or when old code and new code coexist. You’ll see var in legacy scripts, you’ll see mixed module formats, and you’ll see unexpected TDZ errors when a teammate reorganizes a file.
Understanding hoisting is still a competitive advantage. It makes you faster at debugging, more confident in reviews, and more effective at writing code that doesn’t surprise the next person.
Final takeaway: clarity beats cleverness
If there’s one rule I keep returning to, it’s this: Write code so the scope is obvious at a glance. Use const by default, let when you must reassign, and var only in legacy contexts. Put declarations near the top of the block. Don’t reuse names unless you’re very sure it improves clarity. When in doubt, rename.
Hoisting isn’t scary once you see it. It’s just a predictable, consistent set of rules. The bugs happen when we forget those rules exist. Keep the rules close, keep your scopes clean, and the “undefined” panic will become a rare event instead of a recurring one.
If you want to go deeper, my favorite practice is to take a small file in your codebase and annotate each declaration with its scope (global, module, function, block). Do it once, and you’ll feel the difference in how you read JavaScript forever.


