Skip to content
JavaScript Basics: Keep State with Closures

JavaScript Basics: Keep State with Closures

October 17, 2026

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 1

1. 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.mjs

Output:

Readings: 1, 2, 1

Each 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.mjs

Output:

Count: 2
Public count property: false

Both 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.mjs

Output:

Positions: 0, 1, 2
Current label: review

A 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_dir

Acknowledgements

This post was created with assistance from AI.

Last updated on