JavaScript Basics: Keep State with Closures
A function can remember variables from the scope where it was created. Use that ability to make small tools with their own state.
These examples use built-in features and were tested on Linux with Node.js v24.19.0. No packages are needed. Assertions check the results before the program finishes.
Prepare a scratch directory
You need Bash, a text editor, Node.js on your PATH, and the mktemp, rm, and rmdir utilities. Keep using the same terminal. These commands stop the shell if setup fails. Save each named source file as UTF-8; .mjs selects ES modules.
start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 11. Create independent counters
Save this as independent-counters.mjs:
import assert from 'node:assert/strict';
function createCounter() {
let count = 0;
return () => {
count += 1;
return count;
};
}
const firstCounter = createCounter();
const secondCounter = createCounter();
const readings = [firstCounter(), firstCounter(), secondCounter()];
assert.deepEqual(readings, [1, 2, 1]);
console.log(`Readings: ${readings.join(', ')}`);Run it:
node independent-counters.mjsOutput:
Readings: 1, 2, 1Each call to createCounter() creates a separate count binding. The returned function keeps access to its own binding after the outer function returns. This combination is a closure.
2. Share state between methods
Save this as shared-counter.mjs:
import assert from 'node:assert/strict';
function createCounter() {
let count = 0;
return {
increment() { count += 1; },
read() { return count; },
};
}
const counter = createCounter();
counter.increment();
counter.increment();
assert.equal(counter.read(), 2);
assert.equal(Object.hasOwn(counter, 'count'), false);
console.log(`Count: ${counter.read()}`);
console.log(`Public count property: ${Object.hasOwn(counter, 'count')}`);Run it:
node shared-counter.mjsOutput:
Count: 2
Public count property: falseBoth methods close over the same binding. There is no public count property to assign. This is useful encapsulation, not a security boundary against code running in the same process. Closures can retain referenced data for as long as the functions remain reachable.
3. Capture each loop iteration
Save this as loop-closures.mjs:
import assert from 'node:assert/strict';
const readers = [];
for (let position = 0; position < 3; position += 1) {
readers.push(() => position);
}
const positions = readers.map(read => read());
assert.deepEqual(positions, [0, 1, 2]);
console.log(`Positions: ${positions.join(', ')}`);
let label = 'draft';
const readLabel = () => label;
label = 'review';
assert.equal(readLabel(), 'review');
console.log(`Current label: ${readLabel()}`);Run it:
node loop-closures.mjsOutput:
Positions: 0, 1, 2
Current label: reviewA let declaration in this loop creates a fresh binding for each iteration. A shared var binding would give all three readers the final value instead. The label example shows that closures access bindings, not frozen snapshots of their values.
Clean up
In the same terminal, remove only the example files, return to your original directory, and remove the empty scratch directory:
rm -- independent-counters.mjs shared-counter.mjs loop-closures.mjs
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dirAcknowledgements
This post was created with assistance from AI.