Skip to main content
CodeOath
← All posts

JavaScript140 min total · 15 parts

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

Part 5 of 15 · ~6 min

Closures: A Function Remembers Where It Was Created

Time to give the search box a memory.

Right now everything lives in one call to setupSearch(), which means there can only ever be one search box on the page. That is fine until the day you add a mobile layout — and now there are two: one in the header, one in the slide-out menu. They need to track what was typed into them independently. Type clos into the header box and the menu box should not suddenly think you typed clos.

The obvious fix is a factory:

function createSearchBox(inputEl, listEl) {
  let latestQuery = "";           // one per call to createSearchBox
  const recentQueries = [];       // likewise

  function handleInput(event) {
    latestQuery = normalize(event.target.value);
    recentQueries.push(latestQuery);
    listEl.innerHTML = renderRows(search(latestQuery));
  }

  inputEl.addEventListener("input", handleInput);

  return {
    getQuery: () => latestQuery,
    getHistory: () => [...recentQueries],
  };
}

const headerBox = createSearchBox(headerInput, headerList);
const menuBox = createSearchBox(menuInput, menuList);

Now look carefully at what just happened, because it should be impossible.

createSearchBox ran and returned. Its call finished. Every local variable it declared — latestQuery, recentQueries — should, by the rules of every scope chapter you have ever read, be gone. And yet:

headerBox.getQuery(); // "clos" — reading a variable belonging to a function call that ended long ago
menuBox.getQuery();   // ""     — a completely separate one

That is a closure. When a function is created, it keeps a live reference to the scope it was created in, and that reference keeps the scope alive for exactly as long as the function is reachable. handleInput, getQuery, and getHistory were all born inside createSearchBox, so all three hold onto that particular call's variables. The call frame does not get cleaned up, because something still points at it.

Two things follow, and they are the two things worth actually remembering:

Each call gets its own. createSearchBox ran twice, so there are two independent latestQuery variables, and headerBox and menuBox can never step on each other. This is not copying — it is that each call created a genuinely separate scope, and each returned object closed over a different one.

The captured variables are private. There is no way to reach latestQuery from outside except through the functions we chose to return. Not "private by convention", not "private because it starts with an underscore" — genuinely unreachable, because no reference to that scope exists anywhere else in the program. JavaScript had this years before classes got real #private fields, and it is still what those fields compile down to in plenty of toolchains.

That second property is the entire foundation for the rest of this chapter, and for most of the next one.

Closures capture variables, not values

Now we can go back and collect the bug we left sitting in the var/let chapter — the one where every row in the dropdown opened the last article.

Here it is again, with the machinery to actually explain it:

function renderRows(matches) {
  listEl.innerHTML = matches.map((a) => `<li>${a.title}</li>`).join("");
  const rows = listEl.querySelectorAll("li");

  for (var i = 0; i < rows.length; i++) {
    rows[i].addEventListener("click", function () {
      openArticle(matches[i]);
    });
  }
}
// Search "closure", get 3 rows, click any of them → all three open the third article.

The instinct is that each handler "captured i as 0, 1, 2". It did not. A closure captures the variable, not a snapshot of its value — a live reference to the box, not a photograph of what was in it.

And with var, there is only one box. var i is function-scoped, so all three handlers close over the same i. By the time anybody clicks anything, the loop is long finished and that single shared i holds 3. All three handlers read the same variable, all three get 3, and matches[3] is whatever it is.

let fixes it because let in a for loop does something genuinely special:

for (let i = 0; i < rows.length; i++) {
  rows[i].addEventListener("click", function () {
    openArticle(matches[i]); // correct article, every row
  });
}

let creates a fresh binding for every iteration. Not one variable that changes three times — three separate variables, each belonging to one pass through the loop. Three handlers, three different boxes, three different values. Nothing about the closure's behaviour changed; we changed how many variables there were to close over.

Before let existed you had to manufacture that fresh scope by hand, and the standard trick was an IIFE — an immediately invoked function expression:

for (var i = 0; i < rows.length; i++) {
  (function (capturedIndex) {
    rows[capturedIndex].addEventListener("click", function () {
      openArticle(matches[capturedIndex]);
    });
  })(i); // calling it right now copies i's CURRENT value into a brand-new parameter
}

Calling a function creates a scope. Calling it once per iteration creates one scope per iteration, and the parameter capturedIndex is a fresh variable inside each of them. That is precisely what let now does for you automatically — which is most of the reason it replaced var in loops.

You will still meet the IIFE version in older code, and it is worth recognising on sight rather than being puzzled by. It is not a different mechanism. It is the same closure rule, with the scopes created manually.

Closures and memory

Nothing a closure captured can be garbage collected while the closure itself is still reachable — the whole scope comes along for the ride. Read that twice, because it cuts both ways: it's exactly why closures work, and exactly how they leak.

Our search box is now holding recentQueries, the current matches array, and references to two DOM nodes. As long as something can still call handleInput, none of that can be garbage collected. And something can still call it, because we handed it to addEventListener and never took it back:

function createSearchBox(inputEl, listEl) {
  const matches = [];  // could be thousands of article objects
  function handleInput() { /* ... */ }

  inputEl.addEventListener("input", handleInput);
  // Nothing ever removes this listener.
}

On a traditional page, the next navigation wipes everything and the problem evaporates. In a single-page app it does not. Navigate away from the page, mount a new search box, navigate back, mount another — and every abandoned one is still there, held alive by a listener on a DOM node, holding its matches array with it. Ten navigations later you have ten copies of the article index in memory and no way to reach any of them.

The fix is to make teardown part of the API from the start:

function createSearchBox(inputEl, listEl) {
  const matches = [];
  function handleInput() { /* ... */ }

  inputEl.addEventListener("input", handleInput);

  return {
    getQuery: () => latestQuery,
    destroy() {
      inputEl.removeEventListener("input", handleInput);
      // now nothing references handleInput, so the scope it closed over
      // — matches included — becomes collectable
    },
  };
}

Worth being precise about the scale here: a closure holds its whole enclosing scope, not just the variables it happens to mention. handleInput never touches matches in that last snippet, and matches is kept alive anyway. In practice engines are cleverer than that and will often optimise away captures that are provably unused — but it is not a guarantee you should design around, and it stops being true the moment anything in the scope does reference the variable. If a closure is going to outlive the work it was created for, give it a way to be released.