Scope follows where a function is written

Lexical scope means that the variables available to a function depend on where that function is defined in the source code, not where it is called. A function can use its own local variables and variables in surrounding scopes. If two variables have the same name, the nearest available declaration takes precedence.

Try this in a modern browser console or in Node.js: const greeting = "Hello"; function sayHello(name) { console.log(greeting + ", " + name); } sayHello("Sam"); The call prints "Hello, Sam". The function can read greeting because its definition is inside the scope where greeting is available. Moving the call to another function would not change which greeting sayHello uses.

This rule makes code easier to reason about: look at a function’s definition and work outward to find a variable. The location of the call tells you what arguments were passed, but it does not supply a new surrounding scope for the function body.

A closure keeps access to its surrounding bindings

A closure is a function together with access to the variables in its lexical surroundings. That access can remain useful after the surrounding function has finished running. In the same browser console or Node.js environment, enter: function makeCounter() { let count = 0; return function () { count += 1; return count; }; } const next = makeCounter(); console.log(next()); console.log(next()); The calls print 1 and then 2.

Although makeCounter has returned, the returned function can still access count. It does not receive count as an argument on each call; it refers to the binding created during that particular call to makeCounter. If you run const another = makeCounter(); then another() returns 1. The two returned functions have separate count bindings because they came from separate calls.

It helps to think of a closure as retaining access to a binding, not as storing an unchanging photograph of a value. The inner function can update count, and its next call sees the updated value. Other code cannot refer to that local count directly unless makeCounter provides another way to reach it.

Captured variables can change

Closures sometimes surprise developers when a variable changes between creating a function and calling it. Try: let status = "waiting"; const report = () => status; status = "ready"; console.log(report()); The result is "ready", not "waiting". The function reads the current value of the captured binding when it runs.

This distinction matters with callbacks. Suppose a click handler reads a variable that tracks a selected item. If the selection changes before the click, the handler reads the newer selection. That may be exactly what you want. If the handler should instead preserve the selection from setup time, create a new local binding for that setup: function makeReporter(item) { return () => item; } const reportFirst = makeReporter("first"); Each call to makeReporter gives its returned function a distinct item binding.

Be precise about what you are preserving. Capturing an object binding does not make the object immutable: changes to that object’s properties can still be visible later. If a callback needs an independent value, decide which value to copy and when, rather than assuming that creating the callback freezes its surroundings.

A common loop surprise: var versus let

You can see the importance of bindings in a loop. In a modern browser console or Node.js, try: const jobs = []; for (var i = 0; i < 3; i++) { jobs.push(() => i); } console.log(jobs.map(job => job())); The output is [3, 3, 3]. Each function refers to the same function-scoped i binding. By the time the jobs run, the loop has finished and i is 3.

Now change var i to let i and run the example again with a fresh jobs array. The output is [0, 1, 2]. A for loop using let creates a separate binding for each iteration, so each function refers to its iteration’s i. This is not a special rule for arrow functions; the same scope behavior applies to other functions created in the loop.

The practical question is when the function will run. If it runs immediately, you may not notice a shared loop binding. If it runs later, as an event handler or queued task, the difference becomes visible. Using let for a loop index usually expresses the intended per-iteration behavior more clearly.

Use closures to keep state close to its behavior

Closures are useful for small pieces of stateful behavior: counters, configuration-based helpers, and callbacks that need context from setup time. For example: function prefixWith(prefix) { return text => prefix + text; } const warn = prefixWith("Warning: "); console.log(warn("Check the input")); This prints "Warning: Check the input" without requiring every call to repeat the prefix.

Keep the captured state understandable. A function that depends on many changing outer variables can be difficult to test because its result is not clear from its arguments alone. When that happens, pass values explicitly, or put related operations behind a small, clearly named interface. Also avoid retaining large objects in long-lived callbacks when the callback only needs a small piece of their data.

To reason through any closure, ask three questions: where was the function defined, which bindings from that location does it use, and what might change before it runs? Those questions explain both the convenience of a counter that remembers its state and the surprises of a callback that reads a later value.

Back to category ↗