Home / Articles

Where a Function Is Written Decides What It Sees: JavaScript Lexical Scope

Understand how JavaScript resolves a variable name through lexical environments, why the call site never matters for lookup, and how this carries into React handlers.

Publisher: REACTAPP.TOP11 min read2586 words

This article is published in English.

Sources: How JavaScript Finds Variables: Scope, Lexical Environment & Scope Chain

Whenever JavaScript evaluates a name like user, the engine must decide which binding it refers to. The most common mistake, assuming a callee can see its caller's variables, shows up in interviews and production bugs alike. This guide moves from scope to lexical environments to identifier resolution, with extra weight on the difference between the scope chain and the call stack, so you can predict any nested-function puzzle and reason clearly about React handlers.

The question: how does a nested function find outer variables?

Start with three nested levels:

const appName = "HealthApp";

function dashboard() {
  const user = "Jashan";
  function showProfile() {
    const status = "Active";
    console.log(appName);
    console.log(user);
    console.log(status);
  }
  showProfile();
}

dashboard();

showProfile() declares only status, yet all three console.log calls succeed. Each name is found in a different place:

status  → showProfile()
user    → dashboard()
appName → global

Explaining that takes a short sequence of concepts:

Scope
   ↓
Lexical Scope
   ↓
Lexical Environment
   ↓
Scope Chain
   ↓
Identifier Resolution

Scope: where a name is usable

Scope is the region of code in which an identifier can be referenced: it answers "from where can I use this name?"

function dashboard() {
  const user = "Jashan";
  console.log(user); // ✅
}

dashboard();
console.log(user); // ❌ ReferenceError

user belongs to dashboard(); code outside gets a ReferenceError. As a tree:

GLOBAL
│
└── dashboard()
      └── user

Which gives the rule:

Inside dashboard  → accessible ✅
Outside dashboard → inaccessible ❌

Why isolation is worth having

These two functions each declare a data variable:

function loadPatients() {
  const data = ["Patient A"];
}
function loadAppointments() {
  const data = ["Appointment A"];
}

They never collide, because each lives in its own function scope. That isolation keeps code reusable and easier to debug.

The four kinds of scope

JavaScript has four main scope types:

Scope
├── Global Scope
├── Function Scope
├── Block Scope
└── Module Scope

Global scope

In a classic (non-module) script, top-level declarations belong to the global scope and are reachable from anywhere:

var appName = "HealthApp";

function showAppName() {
  console.log(appName);
}

showAppName() finds appName because its lookup path ends at the global environment. One detail: top-level var also becomes a property of the global object (window in browsers), while top-level let and const do not. Keep global state minimal; it couples distant code and complicates testing.

Function scope

Anything declared inside a function is local to it:

function calculateTotal() {
  const price = 100;
  const tax = 20;
  return price + tax;
}
console.log(price); // ❌

price and tax are local, so the final log throws.

var ignores blocks

var is scoped to the nearest function, not the nearest pair of braces:

function example() {
  if (true) {
    var message = "Hello";
  }
  console.log(message); // ✅ Hello
}

The if block does not contain message, so it is still readable after the block.

Block scope with let and const

let and const are confined to the block they are declared in:

if (true) {
  const message = "Payment successful";
  let status = "completed";
}

console.log(message); // ❌
console.log(status);  // ❌

Both logs fail. Loops behave the same way, and each iteration of a for (let ...) loop gets a fresh binding of i:

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

Note that object literal braces describe a value, not a block, so they create no scope.

Module scope

In ES modules, top-level declarations are private to the file:

// auth.js
const token = "secret";
export function getUser() {}

A different module cannot see token:

// dashboard.js
console.log(token); // ❌

Only what a module explicitly exports can be imported elsewhere:

import { getUser } from "./auth.js";

This is the everyday scope in React, Next.js, Vite and modern Node.js projects.

Lexical scope: position in the source code decides

JavaScript is lexically scoped: a function sees the variables of every scope that encloses it in the source text. That structure is fixed when the code is parsed, before anything runs.

const name = "Global";

function outer() {
  const name = "Outer";
  function inner() {
    console.log(name);
  }
  inner();
}

outer();

The program prints:

Outer

inner() is nested inside outer() in the source, so outer's name is the nearest one:

GLOBAL
│
└── outer()
      ├── name = "Outer"
      │
      └── inner()

The lookup for name walks outward from inner and stops at outer:

inner
  ↓
not found locally
  ↓
outer
  ↓
name = "Outer" ✅

Lexical scope is determined by where a function is defined, never by where it is called.

The interview trap: defining versus calling

Now move inner() out of outer(), while outer() still calls it:

const name = "Global";
function outer() {
  const name = "Outer";
  inner();
}
function inner() {
  console.log(name);
}
outer();

A frequent guess is:

Outer

The real output is:

Global

inner() is defined at the top level, next to outer(), not inside it.

GLOBAL
├── name = "Global"
├── outer()
└── inner()

So its lookup path goes straight to the global environment:

inner
  ↓
Global

It does not pass through outer:

inner
  ↓
outer ❌

That outer() invoked inner() changes nothing. The call site shapes the call stack; the definition site determines lexical scope.

Lexical environments: the machinery behind scope

The ECMAScript specification models scope with lexical environments. Each one records two things:

  1. the identifiers bound in that environment
  2. a link to the environment that encloses it

As a mental model:

Lexical Environment
│
├── Environment Record
└── Outer Environment Reference

Recent spec editions fold the outer link into the Environment Record as an [[OuterEnv]] field; the model is unchanged.

The environment record holds the bindings

Take a function with two local constants:

function dashboard() {
  const user = "Jashan";
  const role = "Admin";
}

Conceptually, its environment record maps names to values:

dashboard Environment
user → "Jashan"
role → "Admin"

This is a model, not an object your code can inspect.

The outer reference links environments together

Each environment points to the one that lexically surrounds it:

const appName = "HealthApp";

function dashboard() {
  const user = "Jashan";
}

For dashboard(), that looks like this:

dashboard Environment
│
├── user = "Jashan"
└── Outer
      ↓
Global Environment

That pointer makes lookup beyond the local scope possible. It is set when the function is created, based on where it was defined, which is why the call site does not matter.

Tracing the full chain

Back to the opening example, with the three logs combined:

const appName = "HealthApp";

function dashboard() {
  const user = "Jashan";
  function showProfile() {
    const status = "Active";
    console.log(appName, user, status);
  }
  showProfile();
}

dashboard();

When showProfile() runs, its environments are linked like this:

SHOWPROFILE ENVIRONMENT
│
├── status = "Active"
└── Outer
      ↓
DASHBOARD ENVIRONMENT
│
├── user = "Jashan"
└── Outer
      ↓
GLOBAL ENVIRONMENT
│
├── appName = "HealthApp"
└── Outer
      ↓
     null

The global environment's outer reference is null, ending the chain.

The scope chain and identifier resolution

The scope chain is the route the engine follows to find an identifier: check the current environment, then follow the outer reference, until the name is found or the chain ends.

Current Environment
        ↓
Outer Environment
        ↓
Outer Environment
        ↓
Global
        ↓
null

Finding the binding along that route is called identifier resolution. Take the three lookups:

console.log(status);
console.log(user);
console.log(appName);

Resolving status

Found immediately in the local environment:

showProfile
     ↓
status found ✅

Resolving user

Missing locally, found one level up:

showProfile ❌
     ↓
dashboard
     ↓
user found ✅

Resolving appName

Missing in both functions, found in the global environment:

showProfile ❌
     ↓
dashboard ❌
     ↓
Global
     ↓
appName found ✅

In every case, the search stops at the first match. For a deeper look at the lookup algorithm itself, see how JavaScript's scope chain actually resolves variables.

When resolution fails

Consider a function that reads a name nobody declared:

function showProfile() {
  console.log(age);
}

and a call to it:

showProfile();

If age exists in none of the reachable environments, the search runs out:

showProfile
     ↓
Global
     ↓
null

The engine cannot resolve the name and throws:

ReferenceError: age is not defined

This differs from a variable holding undefined, or a let binding not yet initialized (the temporal dead zone). These three states are easy to confuse:

undefined
uninitialized
undeclared

Three rules that cover most cases

Lookup only goes outward

Inner
  ↓
Outer
  ↓
Global

Inner code can read outer variables. Outer code cannot reach into a nested scope to read variables declared only there.

The nearest binding wins

When the same name exists at several levels, the closest one is used:

const user = "Global User";

function dashboard() {
  const user = "Dashboard User";
  function showProfile() {
    console.log(user);
  }
  showProfile();
}

Output:

Dashboard User

The search ends in dashboard, one step up:

showProfile ❌
     ↓
dashboard ✅
     ↓
STOP

The global user is never consulted. An inner declaration hiding an outer one like this is called shadowing.

Nothing is copied into nested functions

A tempting but wrong picture is that inner functions receive copies of outer variables:

showProfile
├── copied user
└── copied appName

The accurate picture is a chain of references:

showProfile
     ↓
dashboard
     ↓
Global

Reading through a link means the inner function always sees the current value of the outer binding, which is what makes closures work.

Scope chain versus call stack

Here is the trap example again:

const name = "Global";

function outer() {
  const name = "Outer";
  inner();
}
function inner() {
  console.log(name);
}

outer();

While inner() executes, the call stack looks like this:

┌──────────────────┐
│    inner FEC     │ ← Current
├──────────────────┤
│    outer FEC     │
├──────────────────┤
│    Global EC     │
└──────────────────┘

outer() is clearly on the stack, since it called inner(). Yet inner() still prints:

Global

because lookup follows the scope chain, and inner's chain goes straight to the global environment. The two structures answer different questions:

  • The call stack tracks the execution contexts that are currently active; the scope chain resolves identifiers.
  • The call stack is ordered by which function called which; the scope chain is shaped by how the source code is nested.
  • The call stack is last-in, first-out; the scope chain follows outer environment links.
  • The call stack answers "what is running right now?"; the scope chain answers "where does this name live?"

Execution contexts and lexical environments

Execution context

An execution context holds the state needed while code runs. The two main kinds:

Global Execution Context
Function Execution Context

Lexical environment

A lexical environment holds identifier bindings and the link to its outer environment. An execution context refers to its environment, roughly like this:

Execution Context
      │
      └── environment information
               ↓
       Lexical Environment
               │
               ├── bindings
               └── outer environment

So the two are not the same thing:

Execution Context ≠ Lexical Environment

The execution context explains how code runs; the lexical environment explains how names are found.

Lexical scope in React components

React code relies on lexical scope constantly. Consider an event handler defined inside a component:

function UserDashboard({ userId }: { userId: string }) {
  const role = "Admin";

  function handleProfileClick() {
    console.log(userId);
    console.log(role);
  }

  return (
    <button onClick={handleProfileClick}>
      View Profile
    </button>
  );
}

handleProfileClick() declares neither of these names:

userId
role

It uses them anyway because it is written inside UserDashboard:

handleProfileClick
        ↓
UserDashboard
        ├── userId
        └── role

The same mechanism applies to props, state, callbacks, timers, promises and hooks. Each render creates a new environment with that render's values, and handlers defined during it close over exactly those values. That is the basis of closures, and of stale closures, where a callback from an earlier render keeps reading old state.

Common misconceptions

"Lookup follows the caller"

It does not. What counts is the definition site:

Where function is called ❌
Where function is defined ✅

"The call stack and the scope chain are the same"

They serve different purposes:

Call Stack  → execution
Scope Chain → identifier lookup

"Outer code can read inner variables"

Lookup is one-directional:

Inner → Outer → Global ✅
Outer → Inner ❌

"var respects blocks"

It does not; only let and const do:

var       → function-scoped
let/const → block-scoped

"A lexical environment is an ordinary object"

It is a specification concept; engines implement it freely, and your code cannot access it.

A mental model to keep

The topic compresses into a chain of questions:

Scope
   ↓
Where can I use this variable?
   ↓
Lexical Scope
   ↓
Where was this function defined?
Lexical Environment
   ↓
What variables exist here?
Outer Environment
   ↓
Where do I look next?
Scope Chain
   ↓
Current → Outer → Global
Identifier Resolution
   ↓
Find the matching variable

Key takeaways

  • Scope decides where a name can be used; JavaScript has global, function, block and module scope, with var scoped to functions and let/const to blocks.
  • Scope is lexical: a function's outer environment is fixed where it is defined, so calling it from somewhere else never changes what it can see.
  • Identifier resolution walks from the current environment outward, stops at the first match (shadowing), and throws a ReferenceError if it reaches the end.
  • Inner functions hold references to outer environments, not copies, which is the basis of closures and of stale closures in React.