Scope and closures

Know where a name is visible, and use a closure on purpose.

09of 32
≈ 12Min read
8Parts

Scope is where a name can be seen

let and const are visible only inside the nearest { }. var ignores blocks entirely and belongs to the whole function, which is why a var inside an if leaks out of it.

three kinds
const site = "domvybe";          // module scope

function load() {
  const url = "/api";            // function scope

  if (ready) {
    const key = "x";             // block scope
  }

  key;                           // ReferenceError — the block has ended
}

The scope chain

A name is looked up in the current scope, then its parent, and so on out to the global scope. The first match wins, so an inner const total shadows an outer one — usually fine, occasionally the bug.

outwards, never inwards
const a = 1;

function outer() {
  const b = 2;

  function inner() {
    const c = 3;
    return a + b + c;      // inner sees all three
  }

  return inner();
}

// outer cannot see c; the module cannot see b

A closure is a function plus the scope it was born in

n is not garbage collected when counter returns, because the returned functions still reference it. That is all a closure is: the inner function keeps the scope alive.

the canonical example
function counter(start = 0) {
  let n = start;                 // survives after counter() returns

  return {
    next: () => ++n,
    reset: () => { n = start; },
    get value() { return n; },
  };
}

const c = counter(10);
c.next();     // 11
c.next();     // 12
c.value;      // 12
// n is unreachable from outside — this is real privacy

The loop that catches everyone

var versus let
// with var there is ONE i, shared: all three log 3
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i));
}

// with let there is a NEW i per iteration: 0, 1, 2
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i));
}
This is why let exists

Before let, the fix was an immediately-invoked function per iteration to capture the value. let gives each iteration its own binding, which is what people expected all along.

What closures are actually for

four things you will write
// 1. private state
const store = (() => {
  let items = [];
  return { add: (x) => items.push(x), all: () => [...items] };
})();

// 2. a configured function
const prefix = (p) => (s) => `${p}${s}`;

// 3. remembering between calls — debounce
const debounce = (fn, ms) => {
  let t;
  return (...args) => {
    clearTimeout(t);
    t = setTimeout(() => fn(...args), ms);
  };
};

// 4. memoising an expensive result
const memo = (fn) => {
  const cache = new Map();
  return (x) => {
    if (!cache.has(x)) cache.set(x, fn(x));
    return cache.get(x);
  };
};

The cost

A closure keeps its whole scope alive, not just the variables it uses. A handler that closes over a large object holds that object in memory for as long as the handler exists — which is one way a single-page app leaks.

The fix is not to avoid closures; it is to remove listeners when a component goes away, and to close over the one value you need rather than the whole thing it came from.

Globals

Modules are strict by default, so this cannot happen in one. It is the main reason to use type="module" even for a single file.

and how one gets created by accident
function f() {
  total = 10;     // no declaration: in sloppy mode this creates a GLOBAL
}

"use strict";
function g() {
  total = 10;     // ReferenceError — which is what you want
}

IIFE

Before modules, this was how you kept a library's internals private. You still see it in older code, and it is occasionally useful for a block of setup you do not want leaking names.

a scope on demand
(() => {
  const temp = expensive();
  use(temp);
})();   // temp never existed outside

Practice