Skip to main content
CodeOath
← All posts

JavaScript140 min total · 15 parts

JavaScript Core Concepts: Scope, Closures, `this`, and the Event Loop

Part 2 of 15 · ~3 min

Scope: Global, Function, and Block

Scope is the answer to one question: from right here, which names can I see?

That is the whole idea. Everything else is detail. Your search box has names in it — input, resultsList, matches — and each of those names is visible from some regions of the program and invisible from others. Scope is the shape of those regions.

JavaScript draws them at three sizes. Here is our search box with all three labelled:

const ARTICLES = loadArticleIndex(); // global — every function in the file can see this

function setupSearch() {
  const input = document.querySelector("#search-input"); // function scope — visible
  const resultsList = document.querySelector("#search-results"); // anywhere inside setupSearch

  if (!input || !resultsList) {
    const reason = "search box markup is missing"; // block scope — visible only inside these braces
    console.warn(reason);
    return;
  }

  console.log(reason); // ReferenceError: reason is not defined — that name died at the closing brace
}

ARTICLES sits at the top level, so anything in the file can reach it. input and resultsList belong to the call to setupSearch, so anything inside that function — including functions nested inside it, which matters enormously in a few chapters — can reach them. And reason belongs only to the if block that declared it. One line past that closing brace, the name is simply gone.

The reaching-outward part has a name: the scope chain. When JavaScript hits a name it does not recognise, it looks in the current scope, then the scope that encloses that one, then the one enclosing that, out to the global scope, and throws a ReferenceError only when it runs out of places to look. Our event handler leans on this constantly:

function setupSearch() {
  const input = document.querySelector("#search-input");
  const resultsList = document.querySelector("#search-results");

  input.addEventListener("input", function handleInput() {
    // `input` and `resultsList` are not declared in here. JavaScript looks
    // outward, finds them in setupSearch's scope, and uses those.
    const matches = ARTICLES.filter((a) => a.title.includes(input.value));
    resultsList.innerHTML = matches.map((a) => `<li>${a.title}</li>`).join("");
    // `ARTICLES` isn't in setupSearch either — one more hop out, to global.
  });
}

handleInput reads four names it never declared, and each one resolves at a different link in the chain. That is not a special feature for callbacks. It is just the ordinary lookup rule, and it is doing the load-bearing work in almost every handler you have ever written.

The chain runs one way only. setupSearch cannot see matches, because matches lives in a scope nested inside it, and the chain only ever searches outward. An enclosing scope has no idea what its children declared — which is the property that makes it safe to give two different functions a variable called matches without them colliding.

One wrinkle, and it is the single most common source of "why is this variable doing that": var does not participate in block scope at all. It is function-scoped, so it ignores if, for, and bare blocks entirely, as though the braces were not there.

function setupSearch() {
  if (SEARCH_DISABLED) {
    var bailedOut = true;   // var — scoped to setupSearch, not to this if-block
    let quietly = true;     // let — scoped to this if-block only
  }

  console.log(bailedOut); // true — var leaked out of the block into the whole function
  console.log(quietly);   // ReferenceError — let stayed where you put it
}

let and const are block-scoped, which is almost always what you meant. var is function-scoped, which almost never is. The next chapter is about why that difference matters more than it looks.