Confused about when to use Javascript let vs other declarations

I’m running into confusing behavior when I use the Javascript let keyword instead of var or const in my code. Some variables don’t seem to be accessible where I expect them to be, and I’m also seeing errors related to block scope and redeclaration. Can someone explain in simple terms when I should choose let over var or const, and maybe show a few clear examples of best practices for using let in modern Javascript so I can clean up my code and avoid these issues

You are hitting two main things with let:

  1. Block scope
  2. Temporal Dead Zone

Quick breakdown.

  1. Scope differences

var

  • Function scoped.
  • If you declare var x inside an if, x leaks outside that if.

Example:

if (true) {
var a = 1
}
console.log(a) // 1

let

  • Block scoped.
  • Lives only inside the nearest { }.

if (true) {
let b = 2
}
console.log(b) // ReferenceError

Same for for loops:

for (var i = 0; i < 3; i++) {}
console.log(i) // 3

for (let j = 0; j < 3; j++) {}
console.log(j) // ReferenceError

If your code expects a var style leak outside blocks, let will break that.

  1. Temporal Dead Zone (TDZ)

With var:

console.log(x) // undefined, no error
var x = 10

With let:

console.log(y) // ReferenceError
let y = 10

The variable exists from the start of the scope, but you cannot use it before its declaration. That is the TDZ. You see those errors when you access a let variable before the line where you declared it.

  1. let vs const

They have the same scope and TDZ rules.
Difference is mutability.

let x = 1
x = 2 // ok

const y = 1
y = 2 // TypeError

For objects and arrays, const only locks the binding, not the content.

const obj = { a: 1 }
obj.a = 2 // ok
obj = {} // TypeError

  1. When to use what

Practical rule of thumb:

  • Use const by default.
  • Use let if you need to reassign.
  • Avoid var in new code.

Examples of common bugs you are likely seeing:

a) Expecting access outside a block

if (cond) {
let msg = ‘hi’
}
doSomething(msg) // ReferenceError

Fix:

let msg
if (cond) {
msg = ‘hi’
}
doSomething(msg)

b) Using variable before declaration

console.log(total)
let total = 5

Fix: move declaration to the top of the scope.

let total = 5
console.log(total)

If you paste a small snippet of where it breaks, you will see it is either scope of a block or TDZ every time.

One more angle on this, building on what @boswandelaar already laid out nicely.

What usually bites people with let/const vs var is how your mental model of a “scope” is wrong, not the keywords themselves.

You’re probably thinking “scope = file or function.”
JavaScript is more like: “scope = function + every pair of {} created by control structures.”

So things like this:

if (flag) {
  let value = 42
  doSomething(value)
}
useValue(value)   // boom

Your brain: “I just defined value above in the same function, why can’t I use it?”
JS: “Nope, that value only lives inside the if {} block.”

If you intend to use it later, declare the variable in the outer scope, then assign inside the block:

let value

if (flag) {
  value = 42
}

useValue(value)   // value is either 42 or undefined

That pattern is the “fix” in like 70% of the confusing cases.

The other annoying part: mixing var and let in the same function. Example:

console.log(count)
let count = 10
var foo = 1

What a lot of folks expect:

  • count behaves like foo, so it logs undefined

What actually happens:

  • count is in the temporal dead zone so ReferenceError
  • foo is hoisted and initialized to undefined

If your codebase still has a lot of var and you drop in a single let, you get a weird hybrid of behaviors. Personally, I’d say either:

  1. Go all in: replace var everywhere with let / const in that file, or
  2. Stick with var in that legacy function until you refactor it entirely.

I slightly disagree with the idea of “just always use const by default” without thinking. It’s a decent rule of thumb, but it can make code noisier when you’re doing stepwise refinement:

let result

if (cond) {
  result = computeA()
} else {
  result = computeB()
}

return result

For this sort of pattern, forcing const everywhere either leads to unnecessary extra variables or ternaries that hurt readability. So I’d say:

  • Use const for values that genuinely don’t change conceptually
  • Use let when the value naturally evolves over time in that function
  • Use var only when:
    • You absolutely must rely on function scope instead of block scope, or
    • You’re stuck in older patterns and can’t refactor yet

One more subtle trap that might match what you’re seeing:

function example() {
  console.log(a)  // ReferenceError if a is let later in same scope
  {
    let a = 10
  }
}

Some folks expect “the inner block doesn’t matter, I’m before it,” but that inner let a shadows any outer a for the entire scope, and the TDZ kicks in until the declaration line. So even if you later decide to add a let inside a block, it can break earlier console.log(a) that used to refer to some outer value. That feels super random when it happens during refactors.

If you want to track this stuff down in your actual code:

  • Search for the variable name
  • Check if it’s declared with let/const inside a smaller block than where you’re trying to read it
  • Or if you’re touching it anywhere above its declaration line in the same function / block

Every “weird” let bug I’ve debugged in other people’s code has fallen into one of these buckets:

  1. Variable used outside its block
  2. Variable used before declaration because of TDZ
  3. Shadowing: inner let hiding an outer var or parameter
  4. Legacy var assumptions lingering in a partially updated function

If you paste a specific snippet that’s failing, you can usually circle the {} and the declaration in 10 seconds and see exactly which of those 4 it is.