Closures keep values from a particular call
Run two closure-backed counters, rebuild one listener owner, and distinguish live bindings, precomputed strings, object mutation and loop environments.

A closure retains access to bindings in the lexical environment where its function was created. It does not freeze a copy of every visible value. That distinction explains why an event handler can see an updated counter while another function keeps returning a message constructed before the counter changed.
This lesson is for a JavaScript beginner who can read functions and click listeners. The page creates two independent counters and lets you rebuild the first one. Follow three separate things: the binding named count, the number currently stored in it, and a string computed from that number earlier. The example uses small numeric literals; it is not a general counter library with input validation or an unlimited integer range.
One call shares state; another call gets different state
The factory returns three functions. increment changes count, current reads it, and initial reads a different binding containing an already constructed string.
function makeCounter(start, prefix) {
let count = start;
const initialMessage = `${prefix}: ${count}`;
return {
increment() { count += 1; return count; },
current() { return count; },
initial() { return initialMessage; },
};
}
Within one call, increment and current refer to the same count binding. If the first starts at zero, two increments make current() return two. A second call starting at ten has a separate binding; its first increment returns eleven. Identical function source text does not make the two calls share a binding.
The initial message follows another path. When the first factory call runs, the template literal produces Count: 0. Later arithmetic changes count, but does not rerun that earlier expression. initialMessage still holds the original string. The const declaration prevents assigning another value to that binding; string immutability also means the existing string itself cannot be edited into Count: 2.
This is a diagram of observable relationships, not a required stack-and-heap layout. The MDN closures guide describes a function bundled with its lexical environment and shows both shared private state and separate factory calls. An engine can implement that behavior without retaining every local variable exactly as drawn.
Give each counter an explicit owner
Create an ordinary UTF-8 page with a responsive viewport declaration. Put this markup in the body, the CSS in a style element, and both JavaScript blocks in order below the markup. The initial states remain readable without scripting, while buttons stay disabled until their behavior exists.
<main class="counter-demo">
<h1>Two calls, two counter environments</h1>
<div class="counter-grid">
<section id="counter-left" class="counter-panel"
aria-labelledby="title-left" data-prefix="Count">
<h2 id="title-left">First counter</h2>
<p><span id="label-left">Current value</span>:
<output data-current aria-labelledby="label-left">0</output>
</p>
<p>Initial message:
<span data-initial>Count: 0</span>
</p>
<button type="button" disabled>Increment first</button>
</section>
<section id="counter-right" class="counter-panel"
aria-labelledby="title-right" data-prefix="Count">
<h2 id="title-right">Second counter</h2>
<p><span id="label-right">Current value</span>:
<output data-current aria-labelledby="label-right">10</output>
</p>
<p>Initial message:
<span data-initial>Count: 10</span>
</p>
<button type="button" disabled>Increment second</button>
</section>
</div>
<p><button id="rebuild-left" type="button" disabled>Rebuild first at 100</button></p>
<a href="#counter-notes">Read the counter notes</a>
<h2 id="counter-notes" tabindex="-1">Current state and initial message</h2>
<p>Increment changes the current value. The initial message stays as it was when that counter was created. Rebuilding creates a new first counter.</p>
<noscript><p>Enable JavaScript to operate these examples; the initial states remain readable.</p></noscript>
</main>
Each output has a name separate from its changing number. The buttons identify which counter they change. Rebuilding is another action with its own label; it should not silently replace a counter as a side effect of reading it.
.counter-demo, .counter-demo * { box-sizing: border-box; }
.counter-demo {
max-inline-size: 48rem;
margin-inline: auto;
padding: 1.5rem;
color: #303326;
background: #f4f0e6;
font: 1rem/1.6 system-ui, sans-serif;
overflow-wrap: anywhere;
}
.counter-grid { display: grid; gap: 1rem; }
.counter-panel { padding: 1rem; border: 1px solid #77824d; }
.counter-panel h2 { margin-block-start: 0; }
.counter-demo button {
max-inline-size: 100%;
padding: .5rem .75rem;
border: 2px solid currentColor;
color: inherit;
background: transparent;
font: inherit;
overflow-wrap: anywhere;
}
.counter-demo a { color: inherit; text-underline-offset: .2em; }
.counter-demo :where(button, a, [tabindex]) { scroll-margin-block: 1rem; }
.counter-demo :where(button, a, [tabindex]):focus {
outline: 3px solid #1c51a0;
outline-offset: 2px;
}
@media (min-width: 42rem) {
.counter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
The layout stacks at narrow widths and allows long translated labels to wrap. Focus styling and scroll margin keep the controls identifiable during keyboard navigation. These presentation choices do not change the binding relationships.
The following setup function owns one listener. It also returns accessors and a cleanup method, so the caller can end that particular setup before starting another on the same DOM nodes.
function mountCounter(root, start) {
const counter = makeCounter(start, root.dataset.prefix);
const button = root.querySelector('button');
const output = root.querySelector('[data-current]');
const initial = root.querySelector('[data-initial]');
let disposed = false;
output.textContent = String(counter.current());
initial.textContent = counter.initial();
const increment = () => {
output.textContent = String(counter.increment());
};
button.addEventListener('click', increment);
button.disabled = false;
return {
current: counter.current,
initial: counter.initial,
dispose() {
if (disposed) return;
disposed = true;
button.removeEventListener('click', increment);
button.disabled = true;
},
};
}
const leftRoot = document.querySelector('#counter-left');
let left = mountCounter(leftRoot, 0);
const right = mountCounter(document.querySelector('#counter-right'), 10);
const rebuild = document.querySelector('#rebuild-left');
rebuild.addEventListener('click', () => {
left.dispose();
left = mountCounter(leftRoot, 100);
});
rebuild.disabled = false;
Activate the first increment button twice and the second once. The current values become 2 and 11; the initial messages remain Count: 0 and Count: 10. Press the rebuild button and the first pair becomes 100 and Count: 100, while the second counter remains eleven. A later first-counter increment produces 101.
The returned current function reads its factory binding directly. It does not use this, so passing that function around does not require binding a receiver. That is a property of this implementation, not a rule that arbitrary object methods can always be detached safely.
Rebuilding does not rewrite an older closure
A call to mountCounter creates a new counter, increment listener and cleanup state. Assigning its result to the outer left variable changes which handle the rebuild code will use next. It does not alter the bindings read by functions from an earlier factory call.
You can observe that distinction by retaining the old handle before rebuilding. If the old counter had reached two, its saved current() still returns two afterward. The new handle returns 100. Cleanup ends the listener’s participation; it does not erase the old number or make an independently retained getter stop being callable.
The example calls dispose before attaching the replacement listener. If you deliberately mount counters starting at zero and 100 on the same button without that cleanup, one click advances both to one and 101. Both callbacks are valid closures with different state. They also write to the same output, so the later registered callback leaves 101 displayed. The final text alone can hide the fact that two owners responded.
The addEventListener reference explains why separate function objects can both be registered even when their source text is identical. Calling setup again is not an instruction to replace a previous listener.
removeEventListener needs the original callback identity, matching event type and capture setting, as described in the removal reference. Writing a new arrow function with the same body does not identify the registered arrow. Our cleanup closes over the actual increment function, and both registration and removal use the default non-capturing mode.
The disposed flag makes repeated cleanup harmless for this owner. In particular, an old handle that was already disposed cannot disable the replacement’s button when disposed again. That does not create a general resource manager: callers must still follow the one-owner-at-a-time mounting contract, and whole-page teardown would also need to remove the rebuild listener.
Mutation and reassignment follow different bindings
A closure can retain an object reference without receiving a deep snapshot of that object. This independent snippet separates property mutation from replacing an outer variable:
(() => {
function makeReader(source) {
return () => source.title;
}
let model = { title: 'First' };
const read = makeReader(model);
model.title = 'Changed';
model = { title: 'Second' };
console.log(read()); // Changed
console.log(model.title); // Second
})();
The function parameter source receives the first object’s reference. Changing model.title changes that same object’s property, so the returned reader sees Changed. Assigning a new object to the outer model binding afterward does not assign to the parameter binding source. The reader therefore continues using the first object while model.title evaluates to Second.
Both arrows initially reach the same object, but they are not one binding. If the function had instead been written directly as () => model.title in the outer scope, it would read the outer binding and observe Second. Choosing between those designs is a choice about ownership: retain the object supplied to a particular call, or consult whichever object a shared binding currently names.
Likewise, const model = {...} would prevent reassigning that binding, but would not freeze the object’s properties. A stale display can come from a precomputed string, a retained older object, or a listener from an earlier setup. Naming the exact binding and operation gives a more precise diagnosis than treating every case as the same “stale closure” problem.
Loop callbacks expose the same rule
Run this small console example separately:
(() => {
const shared = [];
for (var i = 0; i < 3; i += 1) shared.push(() => i);
const separate = [];
for (let j = 0; j < 3; j += 1) separate.push(() => j);
console.log(shared.map(read => read())); // [3, 3, 3]
console.log(separate.map(read => read())); // [0, 1, 2]
})();
The var loop has one function-scoped i. All three readers refer to it, and the loop leaves it at three before the readers run. The let declaration in the second loop header creates the relevant per-iteration bindings, so those readers return zero, one and two.
Merely changing a declaration somewhere to let is not the whole explanation. Declare let i before the loop and update that one binding in the loop, and later readers still all see three. The declaration’s location and the loop’s binding rules matter. Shared state is sometimes intentional; use separate per-call or per-iteration bindings when independence is the actual requirement.
Cleanup and garbage collection answer different questions
A listener attached to a long-lived target can keep its closure and referenced data reachable. Removing a visual panel does not remove a listener attached elsewhere, such as the document. Conversely, a detached node and its listener cycle are not automatically a leak if nothing reachable still points to them.
The MDN memory-management guide explains reachability and garbage collection. In this example, keeping an old getter deliberately keeps its observable counter state available. Removing the old listener changes the event graph; it does not prove when an engine will reclaim memory, how many bytes it retained, or whether another reference still exists. Use heap investigation for a memory-growth claim instead of counting closures in source code.
Author checks exercised the complete page in Chrome 153 using native keyboard and pointer activation, both languages and actual default-font preferences of sixteen and thirty-two pixels. Repeated rebuilding retained one active owner; a deliberately overlapping setup reproduced two independent updates. The loop, object-reference and listener-identity controls also produced their stated results. These checks establish finite behavior, not a cross-browser accessibility verdict or garbage-collection measurement.
For a final exercise, save const readBefore = left.current, rebuild the first counter, then compare readBefore() with left.current(). Next replace the parameter-based reader with the direct outer-binding reader from the object discussion. Before running either change, draw which binding each function reads. The result should follow from those arrows rather than from the label “closure.”