Closure Fundamentals
Several methods can share one private balance. Two calls to the same factory can have different balances. A closure keeps a function connected to the surrounding bindings it can use, including after the call that created it has finished.
Use this reference when you want one state or lifetime recipe. Start with the counter or account-style factory. The subscription and asynchronous examples are optional depth, not a checklist to finish before moving on.
Core Concepts
| What you need | Recipe | Extra preparation |
|---|---|---|
| Several operations on one private value | Counter or account-style state | Functions, returns and objects |
| Private helper behavior | Private helpers | Function calls and strings |
| Explicit callback registration and removal | Event manager | Callback identity, Map and Set |
| A saved sequence of function arguments | Fixed-arity closure chain | Returned functions |
| Reuse of a pure calculation | Small numeric cache | Map lookups |
| Replay of a retained latest value | Optional observable | Subscription lifecycle |
| Updates that finish asynchronously | Optional async state | Promises and await |
Running the examples: every JavaScript pane is a complete, independent Node.js 24 ES module. Save one as example.mjs and run node example.mjs in a fresh process. The async-state pane uses top-level await. No TypeScript, browser DOM or external service is required.
Each recipe states its own bounds. Private state is not the same as validated input, cancellation or automatic cleanup. Keeping a returned function or object can keep the state needed by its methods available. Removing one registration does not prove that every reference to a callback has gone away or that garbage collection happens immediately.
Implementation Patterns and Best Practices
Counter: shared methods, independent instances
The methods returned by one call all use the same count. A second call creates another count.
Use ordinary integer values and keep these example counts within JavaScript's safe integer range. This small counter demonstrates state sharing, not a general numeric-validation API.
function createCounter(initialValue = 0) {
let count = initialValue
return {
increment() {
return ++count
},
decrement() {
return --count
},
getValue() {
return count
},
}
}
const first = createCounter(10)
const second = createCounter()
console.log(first.getValue()) // 10
console.log(first.increment()) // 11
console.log(first.decrement()) // 10
console.log(second.getValue()) // 0
| Operation | First counter's binding | Second counter's binding |
|---|---|---|
| Create both counters | 10 | 0 |
| Increment the first | 11 | 0 |
| Decrement the first | 10 | 0 |
This is a logical state trace, not a diagram of an engine's memory layout. No this receiver is needed by these methods because they read the local count directly.
Account-style state with visible failures
Here a deposit and a withdrawal share one private balance. The rules are deliberately small:
- The initial balance is a non-negative safe integer. Omitting it uses
0. - Amounts must be positive safe integers, with no string coercion.
- Invalid input, an excessive withdrawal or a deposit that would leave the safe integer range throws
RangeErrorbefore changing the balance. - A successful operation returns the new balance.
These are whole-unit example values, not a financial library. Decimal money, rounding, persistence, authorization and concurrent transactions are outside this recipe.
function createAccount(initialBalance = 0) {
if (!Number.isSafeInteger(initialBalance) || initialBalance < 0) {
throw new RangeError('Initial balance must be a non-negative safe integer')
}
let balance = initialBalance
function checkAmount(amount) {
if (!Number.isSafeInteger(amount) || amount <= 0) {
throw new RangeError('Amount must be a positive safe integer')
}
}
return {
deposit(amount) {
checkAmount(amount)
const nextBalance = balance + amount
if (!Number.isSafeInteger(nextBalance)) {
throw new RangeError('Balance would exceed the safe integer range')
}
balance = nextBalance
return balance
},
withdraw(amount) {
checkAmount(amount)
if (amount > balance) {
throw new RangeError('Insufficient balance')
}
balance -= amount
return balance
},
getBalance() {
return balance
},
}
}
const account = createAccount(1000)
const otherAccount = createAccount()
account.deposit(500)
account.withdraw(200)
console.log(account.getBalance()) // 1300
console.log(otherAccount.getBalance()) // 0
try {
account.withdraw(2000)
} catch (error) {
console.log(error.message) // Insufficient balance
}
try {
account.deposit(-5)
} catch (error) {
console.log(error.message) // Amount must be a positive safe integer
}
console.log(account.getBalance()) // 1300
The errors are visible to the caller. They are not silent ignored updates. balance is private because callers receive methods rather than a reference to its binding.
Event manager: remove the same callback you registered
This is a local event registry, not a DOM listener wrapper. Use string event names and function handlers. The same function registered twice for the same name is stored once. A different function with identical source text is still a different registration.
emit synchronously calls the handlers for that name in registration order. With no registered handler it does nothing. A handler exception propagates to the caller of emit and stops that dispatch. Handlers must not change subscriptions or reenter dispatch in this small recipe.
off returns whether it removed that function. cleanup clears all registrations in this manager. It is not terminal destruction: later registration is allowed. Neither operation cancels external work or guarantees collection of the callback.
function createEventManager() {
const handlers = new Map()
return {
on(event, handler) {
if (typeof handler !== 'function') {
throw new TypeError('Handler must be a function')
}
if (!handlers.has(event)) {
handlers.set(event, new Set())
}
handlers.get(event).add(handler)
},
off(event, handler) {
const registered = handlers.get(event)
if (!registered) return false
const removed = registered.delete(handler)
if (registered.size === 0) handlers.delete(event)
return removed
},
emit(event, data) {
handlers.get(event)?.forEach((handler) => handler(data))
},
cleanup() {
handlers.clear()
},
}
}
const events = createEventManager()
const seen = []
const handler = (value) => seen.push(value)
events.on('update', handler)
events.on('update', handler)
events.emit('update', 1)
console.log(events.off('update', handler)) // true
events.emit('update', 2)
events.on('update', handler)
events.cleanup()
events.emit('update', 3)
events.on('update', handler)
events.emit('update', 4)
console.log(JSON.stringify(seen)) // [1,4]
events.cleanup()
The postcondition of cleanup is an empty registration map, not an unreachable manager. The caller still holds events in this example.
Optional: private helpers without changing state
A closure can also keep helper behavior out of the public API. This factory accepts a string unit name. formatDouble accepts a finite number whose double remains finite and returns a string.
function createFormatter(unit) {
function double(value) {
return value * 2
}
function format(value) {
return `${value} ${unit}`
}
return {
formatDouble(value) {
return format(double(value))
},
}
}
const formatter = createFormatter('points')
console.log(formatter.formatDouble(3)) // 6 points
The public object exposes formatDouble, not double or format. Unlike the counter, this recipe has no changing private value. Its useful distinction is the small public interface around private helpers.
Fixed-arity closure chain
This is hand-written three-stage addition, not a general curry(fn) utility. Supply one finite number at each stage and keep the total finite. It does not model grouped arguments, placeholders or receiver-dependent functions.
Each returned function remembers the bindings from its particular call. Saving a partial result does not consume it, so it can be reused.
function addThree(a) {
return function (b) {
return function (c) {
return a + b + c
}
}
}
const addOne = addThree(1)
const addOneAndTwo = addOne(2)
console.log(addOneAndTwo(3)) // 6
console.log(addOne(4)(5)) // 10
console.log(addThree(10)(20)(30)) // 60
The chain illustrates currying. Saving addThree(1) also fixes one argument, which is partial application of this curried function. A general partial-application helper for an ordinary multi-argument function has a different calling contract.
For grouped calls, reusable partials and a specified receiver policy, use Currying and Partial Application. That task defines the general utility's bounds rather than making this small example carry them implicitly.
A small cache around a pure numeric calculation
This cache accepts only integer numbers from 0 through 1000, inclusive. Other inputs throw RangeError without adding an entry. Squaring is a pure calculation for this domain and does not depend on the caller's receiver.
The computing log is only a trace that makes misses visible. Squaring a small number is cheap, so this is not a claim that caching makes this calculation faster.
function createSquareCache() {
const cache = new Map()
return function square(value) {
if (!Number.isInteger(value) || value < 0 || value > 1000) {
throw new RangeError('Use an integer number from 0 through 1000')
}
if (cache.has(value)) {
return cache.get(value)
}
console.log('computing', value)
const result = value * value
cache.set(value, result)
return result
}
}
const square = createSquareCache()
console.log(square(4))
// computing 4
// 16
console.log(square(4)) // 16
console.log(square(0))
// computing 0
// 0
console.log(square(0)) // 0
try {
square(-1)
} catch (error) {
console.log(error.message) // Use an integer number from 0 through 1000
}
Using has distinguishes a stored zero from an absent entry. 0 and -0 share a key and both square to 0. Each factory call creates a separate cache. This version has no eviction or reset method and can retain one entry per accepted key.
Objects, multiple arguments and receiver-sensitive work need a different contract. Implement memoize covers identity-based keys, receivers and retained results. This recipe does not serialize arbitrary inputs or promise automatic cache cleanup.
Optional observable: no value is different from undefined
Use this when a new subscriber should immediately receive the latest notified value. Before the first notification there is nothing to replay. After notify(undefined), there is a value to replay: undefined.
Subscribers must be synchronous and non-throwing. They must not add or remove subscriptions during notification or reenter these operations. Repeated registration of the same function uses one Set entry. Each subscribe call still replays the latest value if one exists. Calling either returned disposer removes that registration.
function createObservable() {
const observers = new Set()
let hasValue = false
let currentValue
return {
subscribe(observer) {
if (typeof observer !== 'function') {
throw new TypeError('Observer must be a function')
}
observers.add(observer)
if (hasValue) {
observer(currentValue)
}
return () => {
observers.delete(observer)
}
},
notify(value) {
currentValue = value
hasValue = true
observers.forEach((observer) => observer(value))
},
}
}
const observable = createObservable()
const stopFirst = observable.subscribe((value) => {
console.log('first:', value)
})
observable.notify(undefined) // first: undefined
const stopReplay = observable.subscribe((value) => {
console.log('replay:', value)
}) // replay: undefined
stopFirst()
stopReplay()
observable.notify('retained')
const stopLast = observable.subscribe((value) => {
console.log('latest:', value)
}) // latest: retained
stopLast()
Unsubscribe prevents later notifications to that callback. It does not clear currentValue. A later subscriber can still receive the retained value. Repeating an unsubscribe is harmless.
Callback exceptions are outside this recipe's contract. A throw during notify can happen after the latest value has been stored and stop the remaining notifications. A throw during initial replay can prevent subscribe from returning its disposer even though registration already happened. There is no rollback, complete-notification or guaranteed-cleanup promise for those unsupported cases.
Optional async state: completion order, not latest call wins
This recipe requires an explicit initial value. Omitting it throws TypeError. Explicitly passing undefined is allowed if that is the intended initial value.
subscribe registers a listener without immediately invoking it. Listeners must be synchronous and non-throwing, with no subscription changes during notification and no reentrancy. The same function is stored once, and either disposer for that function removes it.
setState accepts a value or promise and returns a promise. It awaits the input before assigning state, then synchronously notifies the current listeners. Even a plain value goes through that asynchronous continuation.
function createAsyncState(initialValue) {
if (arguments.length === 0) {
throw new TypeError('Provide an initial value')
}
let state = initialValue
const listeners = new Set()
return {
async setState(newState) {
const nextState = await newState
state = nextState
listeners.forEach((listener) => listener(state))
},
getState() {
return state
},
subscribe(listener) {
if (typeof listener !== 'function') {
throw new TypeError('Listener must be a function')
}
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
}
}
const state = createAsyncState(0)
console.log(state.getState()) // 0
const unsubscribe = state.subscribe((value) => {
console.log('updated:', value)
})
await state.setState(42) // updated: 42
unsubscribe()
await state.setState(43)
console.log(state.getState()) // 43
try {
await state.setState(Promise.reject(new Error('load failed')))
} catch (error) {
console.log(error.message) // load failed
}
console.log(state.getState()) // 43
let resolveOlder
const olderInput = new Promise((resolve) => {
resolveOlder = resolve
})
const olderUpdate = state.setState(olderInput)
await state.setState(2)
console.log(state.getState()) // 2
resolveOlder(1)
await olderUpdate
console.log(state.getState()) // 1
The example awaits the first notification before unsubscribing. After removal, that listener does not receive 43 or the later updates. Removing a listener does not cancel a pending state update.
When the supplied input promise rejects, that call propagates the rejection before assignment. It does not change state or notify listeners. An unrelated successful update could still have changed state while it was pending.
That guarantee does not cover a throwing subscriber. Such a callback is outside the contract and can make setState reject after the new state has been committed. Remaining listeners may not run. No rollback or complete-notification guarantee is provided.
The final value is 1 because the older pending update completes after the update to 2. This is completion-order behavior, not latest-invocation-wins. Cancellation, request versioning and subscriber error recovery would be separate policies, not consequences of using a closure.
Transfer to timer utilities
Closures can retain a pending timer or a gate between calls, but the timing policy still needs its own contract. Use Debounce vs Throttle for the existing leading-only throttle and Implement debounce for trailing-only debounce.
Those tasks specify arguments, receivers, timer ordering and errors. There is no second hybrid throttle to copy from this page. Keep the recipe that answers your current question and follow the specialist task when you need its broader behavior.