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
// 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.
// 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
};
} // 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.
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.
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
recordandreadcan touch a job’scompleted. There’s no variable to reset by accident. - Independent jobs
- Each
createProgresscall makes its own count, so B stays at 0 while A records. - Callbacks you can hand out
alsoRecordAgoes to the parser and still records into A, wherever it’s called from.- A report that stays put
captureReportkeeps the number it read, so it still says 1 later.- A status that stays current
liveStatusreads 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.
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.
Needs the current count
It adds one to whatever the count is when the row lands.
Captured on purpose
Read once, when the click starts the import.
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.
// 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.
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.
| Where you’ve seen it | What’s captured | When it’s read |
|---|---|---|
| A React event handler | That render’s props and state | Whenever it runs, with that render’s values |
for (var i …) making callbacks | One i for the whole loop | Later, after the loop has finished |
A Go for loop starting goroutines | A new loop variable each iteration, from Go 1.22 | Whenever each goroutine runs |
A middleware factory, withAuth(config) | config, from the factory call | On every request |
rows.filter((row) => row.owner === userId) | userId | Right 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.
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.
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.
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.
| The change | One page count | One count per job |
|---|---|---|
| One import at a time | Works. | Works, with one more call. |
| Retry failed rows alongside | Both jobs’ counts mix. | Each job counts its own rows. |
| Show a total across imports | Already there. | Add the jobs up. |
| Drop a canceled job | Its rows stay in the page count. | Stop holding its functions. |
| Test a job | Needs 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
- Values and references explains the difference between keeping a variable and keeping the value it held.
- The call stack and execution shows the setup call returning while the functions it made carry on.
- Race conditions in UI is what happens when an old request’s callback still writes to the screen.