← Concepts & practices
Concept Language and runtime models

Closures and captured state

What does this function keep?

You already pass callbacks that run long after the code that made them has returned. Let’s follow a contacts import whose progress callbacks work, until a second job reads the first job’s count and a status line stays at 0 while rows pour in.

TypeScriptGo One import job, two implementations.

01 / The idea

Two callbacks are a fair interface for an import.

You’re building the contacts importer for a CRM. The import page hands each job two functions: record, which the parser calls after every row, and read, which the progress bar calls to draw itself. No global variable, no class, and the parser never sees how the count is stored. With one import at a time, it works.

Read the first import pageTypeScript · the version this lesson starts from
progress.ts
// The first version: the import page keeps one count, and every job it starts records into it.
// With one import at a time, that's all the page needs.
export function createImportPage() {
	let completed = 0;
	return {
		startJob: (): Progress => ({
			record: () => (completed += 1),
			read: () => completed
		})
	};
}

Go’s version returns the same two function literals in a struct. Both languages meet again at createProgress in section 02.

Then someone adds “retry failed rows”, which runs as a second job while the first is still going. The retry’s progress bar starts at 40. And the status line under the bar, built when the job starts, says “0 of 120 rows” until the import finishes.

A function keeps access to the variables around the place it was made: not copies of them, and not whatever the caller has nearby. Every call to the function that makes it creates new variables, shared by all the functions made in that call. And a value read while setting up is just a number; only a read inside the returned function happens again. MDN puts the first part in one line: “In JavaScript, closures are created every time a function is created, at function creation time.”

Section 05 builds an import timer and a contact importer whose callbacks run after the click that started them, in React and Svelte.

02 / See the shape

Make state in the call it belongs to, and read it when you need it.

The basic form makes one count per call. In the wild decides when the count is read: once, for a report, or every time, for a status line. At the call site runs two jobs from one page, then two separate jobs with an alias, a report, and a status line.

Both languages produce the same five results.

One count per call. createProgress makes a new completed each time it runs, and returns two functions that share it.

TypeScriptReading
progress.ts
// Each call makes a new count. The two functions it returns share that one, and nothing else can reach it.
export function createProgress(): Progress {
	let completed = 0;
	return {
		record: () => (completed += 1),
		read: () => completed
	};
}
GoAlongside
progress.go
// NewProgress makes a new count on each call. The two functions share that one, and nothing
// else can reach it.
func NewProgress() Progress {
	completed := 0
	return Progress{
		Record: func() int { completed++; return completed },
		Read:   func() int { return completed },
	}
}
Reading the TypeScriptArrow functions and lexical scope

record and read are arrow functions that use completed from the surrounding call. MDN’s makeAdder example says two such functions “share the same function body definition, but store different lexical environments.”

captureReport calls read() on its first line and keeps the result in a const. liveStatus doesn’t call it until its own function runs.

Reading the GoFunction literals share their variables

The Go specification says function literals “may refer to variables defined in a surrounding function. Those variables are then shared between the surrounding function and the function literal, and they survive as long as they are accessible.”

alsoRecordA := a.Record copies a function value, and the copy still refers to A’s completed. It isn’t a new counter.

03 / Follow the counts

Watch which box each function reaches.

Five steps. Each box is one piece of captured state, with the functions that can reach it. The numbers come from calling the lesson’s functions. Before each step, guess which box changes.

In Try it, record through either name, capture reports, and watch what stays put.

Captured state

Where does each count live?

Two jobs, one page count. createImportPage() keeps completed = 3, reached by first.record, first.read, second.record, second.read. first.read() returns 3; second.read() returns 3. The first job recorded twice and the second once. Both jobs’ functions were made inside the one page call, so they all reach its one count.

01/ 05
Start two jobs from one page

Two jobs, one count.

Both jobs were started by the same page, so their functions share the page’s count. The second job reads 3.

Reduced motion: choose a scene to see its completed state.

Read this scene

Both jobs were started by the same page, so their functions share the page’s count. The second job reads 3.

Two jobs, one page count. createImportPage() keeps completed = 3, reached by first.record, first.read, second.record, second.read. first.read() returns 3; second.read() returns 3. The first job recorded twice and the second once. Both jobs’ functions were made inside the one page call, so they all reach its one count.

Watch restarts when you return. Step through keeps your selected step. Try it starts with new counts each time you open it.

What well-placed state buys you

Now put names on what you just watched. These are the words you’ll hear in a design review, and each one points at something on this page.

State nobody else can reach
Only record and read can touch a job’s completed. There’s no variable to reset by accident.
Independent jobs
Each createProgress call makes its own count, so B stays at 0 while A records.
Callbacks you can hand out
alsoRecordA goes to the parser and still records into A, wherever it’s called from.
A report that stays put
captureReport keeps the number it read, so it still says 1 later.
A status that stays current
liveStatus reads on every call, so it says 2 as soon as A is 2.

The review words are closure, lexical scope, captured variable, snapshot, and stale closure for a callback still holding values from an earlier moment. Section 08 covers what they cost.

04 / Try a decision

A status line stuck at zero.

The import panel builds its status line when the job starts and shows it after every row. The code is in status.ts, and the lesson’s tests pin what happens.

After three rows are imported, what does the status line show?

When the job starts, the panel builds line = statusLine(progress, 5), where statusLine runs const completed = progress.read() and returns () => `${completed} of ${total} rows`. Each row calls progress.record().

05 / Give it a real job

Callbacks that run after the click has finished.

In the real importer, clicking Import reads the CSV file, then imports its rows one at a time while the page stays usable. The row callback runs long after the click handler started, and the finished message has to name the file that was imported, even if someone picks another file meanwhile.

Row callback

Needs the current count

It adds one to whatever the count is when the row lands.

File name

Captured on purpose

Read once, when the click starts the import.

Progress bar

Reads on every render

It draws from state and keeps nothing of its own.

The example leaves out canceling an import, failed rows, and sending contacts to a server. It disables the button while a job runs, so two imports never share the panel.

Build UIs?Every event handler and effect you write is a closure, and one day a callback runs with values from a render that has already passed.

Where it already is in your components

React’s documentation is direct about what a handler captures: “A state variable’s value never changes within a render, even if its event handler’s code is asynchronous.” An interval made in an effect keeps the render it was made in, so the textbook timer uses setSeconds(s => s + 1). React’s useEffect reference uses the same fix, noting that the effect then “won’t need to cleanup and setup the interval again every time the count changes.”

Svelte’s state is a variable. Its docs say count “is just a number, rather than an object or a function, and you can update it like you would update any other variable.” The Svelte timer’s interval writes seconds += 1, and its effect’s teardown clears the interval.

When you have to own it

Now it’s the contact importer. importRows calls onRow after each row, well after the click. In React, setImported(imported + 1) would read the click’s render every time, so the count would stop one past wherever it stood when the button was clicked. The row callback passes an updater instead. The Svelte version writes the variable directly.

Both capture file.name before the first await, which is the snapshot you want here. file.text() returns “a promise that resolves with a string”, and has worked across browsers since April 2021.

import-rows.ts
// Imports rows one at a time, handing control back between rows the way a real parser does
// between chunks. onRow runs after each row, long after the caller's render or click has finished.
export async function importRows(rows: string[], onRow: (row: string) => void): Promise<number> {
	let imported = 0;
	for (const row of rows) {
		await Promise.resolve();
		onRow(row);
		imported += 1;
	}
	return imported;
}

// The non-empty lines of a CSV file, header excluded.
export function contactRows(text: string): string[] {
	return text
		.split('\n')
		.slice(1)
		.filter((line) => line.trim() !== '');
}

An elapsed-time counter for a running import: React counts with an updater inside the interval, and Svelte adds to its state variable.

ReactAlready in your code
ImportTimer.tsx
import { useEffect, useState } from 'react';

export function ImportTimer({ running }: { running: boolean }) {
	const [seconds, setSeconds] = useState(0);

	useEffect(() => {
		if (!running) return;
		const interval = setInterval(() => {
			// This callback was made in one render. setSeconds(seconds + 1) would keep using that
			// render's seconds; the updater receives the latest value instead.
			setSeconds((s) => s + 1);
		}, 1000);
		return () => clearInterval(interval);
	}, [running]);

	return <p>Importing for {seconds}s</p>;
}

06 / Recognize it elsewhere

Anywhere a function outlives the code that made it.

You’ve met all of these. For each one, find what’s captured and when it’s read.

Familiar closures, what they capture, and when they read it
Where you’ve seen itWhat’s capturedWhen it’s read
A React event handlerThat render’s props and stateWhenever it runs, with that render’s values
for (var i …) making callbacksOne i for the whole loopLater, after the loop has finished
A Go for loop starting goroutinesA new loop variable each iteration, from Go 1.22Whenever each goroutine runs
A middleware factory, withAuth(config)config, from the factory callOn every request
rows.filter((row) => row.owner === userId)userIdRight away, during the call

Before trusting a callback, find the call that made its variables. Then check whether it reads them when it runs or read them once, earlier.

07 / Already in your toolbox

Your languages already document what a function keeps.

Three places to look. For each one, find what’s shared and what’s fixed.

MDN · Closures

Lexical scope, function factories that store different environments, and “Creating closures in loops: A common mistake”, where three callbacks share one var.

Read the guide ↗

React · State as a snapshot

Why a handler sees the state of the render it came from, with a setTimeout example that shows the old value after the state has changed.

Read the page ↗

Go blog · Fixing for loops in Go 1.22

How loop variables became per-iteration, what that fixed for goroutines and closures made in loops, and which go.mod version turns it on.

Read the post ↗
A useful counterexample: one total for the whole pageWhen sharing captured state is right

A dashboard that shows “312 contacts imported today” across every job wants every job’s record to reach one count. That’s createImportPage, used on purpose.

08 / The parts to watch

Closures keep more, and less, than they seem to.

These are the places it still goes wrong.

A read during setup is a snapshot

const completed = progress.read() outside the returned function runs once. Move the read inside it if the value should follow the count.

Another name isn’t another count

Assigning a function to a new variable, or passing it to a parser, keeps its variables. Call the factory again for independent state.

A React callback keeps its render

Timers, row callbacks, and anything else that runs after an await see the state from the render that made them. Use an updater, or a ref for values that aren’t state.

var shares one variable across a loop

Callbacks made in a for (var …) loop all see its final value. let and const make one variable per iteration.

Captured state lives as long as the function

A listener that’s never removed keeps everything it captured reachable. Remove subscriptions and clear timers when the job or component goes away.

Shared captured state isn’t safe across goroutines

Two goroutines calling the same Record race on completed. The Go memory model says such access “must” be serialized, with channels, sync, or sync/atomic.

09 / Make the call

What would you have to change tomorrow?

Give both kinds of progress a plausible change and follow the work it creates.

How a change affects a shared page count and one count per job
The changeOne page countOne count per job
One import at a timeWorks.Works, with one more call.
Retry failed rows alongsideBoth jobs’ counts mix.Each job counts its own rows.
Show a total across importsAlready there.Add the jobs up.
Drop a canceled jobIts rows stay in the page count.Stop holding its functions.
Test a jobNeeds a fresh page per test.Needs a fresh call per test.

Make state inside the call that matches its lifetime: one call per job for a job’s count. A second job running alongside is the moment.

Keep the shared count when every caller really should add to one total.

The question I’d leave beside the code is: what does this function capture, who else can change it, and when is it read?

10 / Take the idea with you

Explain the stuck status line without saying “closure.”

“The status line read the count once, when the job started, and kept that 0. Reading the count inside the function that draws the line fixed it.” In a review, the words are closure, captured variable, snapshot, and stale closure.

Before moving on, jot down why the retry job started at 40, why the status line stayed at 0, and one callback in your own code that runs after the code that made it has returned.

Connections to follow nextRelated lessons

Take the callbacks into your editor. Add a reset function to createProgress, and work out what an earlier report says after it runs.

Back to Concepts & practices →