Home / Articles

Closure-Backed Factories: Private State, Debounce, Rate Limits and Undo

See how closures keep private state alive in JavaScript factories, why they cause stale values in React timers, and how debounce, rate limiting and undo depend on them.

Publisher: REACTAPP.TOP7 min read1631 words

This article is published in English.

Sources: Closures in JavaScript and React, Explained Simply

Closures are behind a surprising amount of everyday JavaScript: counters, debounced inputs, request throttles, undo stacks and nearly every React hook. They are also behind a whole family of bugs where a callback reports a value that is no longer true. This guide builds a working definition, shows what it means for React state, and then walks through four practical factory patterns, including the limits you should know before using them in production.

A working definition

A closure is a function bundled together with the variables that were in scope where it was defined. The function keeps access to those variables even after the outer function that declared them has returned. A useful picture is a function that leaves home carrying a bag with the variables it references; wherever it is called later, it reaches into that same bag.

The counter factory

The classic demonstration is a factory that returns a function which updates a variable it does not own:

function makeCounter() {
  let count = 0; // lives inside makeCounter

  function increment() {
    count = count + 1;
    console.log(count);
  }

  return increment;
}

const counter = makeCounter();
counter(); // 1
counter(); // 2
counter(); // 3

Step by step, this is what happens:

  • makeCounter() runs a single time and declares count.
  • It returns increment, which references count, and then makeCounter finishes.
  • Each call to counter() reads and updates that same count, which is why the output climbs from 1 to 3.

Ordinarily the local variables of a function become unreachable when it returns, and the engine is free to reclaim them. Here, increment still refers to count, so the variable stays alive for as long as increment does. If you want the underlying rules for how the engine finds count in the first place, the article on how the scope chain resolves variables covers that mechanism.

Why React state goes stale inside timers

React does not add anything special on top of closures; it simply relies on them everywhere, especially in hooks. That reliance explains one of the most common surprises in component code:

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setTimeout(() => {
      console.log("Count is: " + count);
    }, 3000);
  }

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>Add</button>
      <button onClick={handleClick}>Log count in 3 seconds</button>
    </div>
  );
}

Start with count at 0. Click "Log count in 3 seconds," then press "Add" three times before the timer fires. The console prints 0, not 3.

The reason is that every render of Counter is a fresh function call. Each call gets its own count constant and defines its own handleClick. The setTimeout callback was created during the render in which count was 0, and that is the only count it can ever see.

This model explains most "why is my state out of date?" questions, and it is the purpose of the dependency array in useEffect: it tells React which captured values the effect relies on, so React can re-run it with fresh ones when they change. When you genuinely need the latest value inside a delayed callback, the usual options are a functional update such as setCount(c => c + 1) for writes, or a ref that you keep up to date for reads. The trade-offs between those choices are explored in snapshot versus live values in delayed callbacks.

Four factory patterns you will meet in real code

Encapsulated state without classes

Before class fields with the # prefix became part of the language, closures were the standard way to hide data, and they remain a lightweight option today. Anything declared in the factory is reachable only through the functions it returns:

function createBankAccount(balance) {
  return {
    deposit(amount) {
      balance += amount;
      return balance;
    },
    withdraw(amount) {
      balance -= amount;
      return balance;
    },
    getBalance() {
      return balance;
    }
  };
}

const account = createBankAccount(100);
account.deposit(50);
console.log(account.getBalance()); // 150
console.log(account.balance); // undefined — can't touch it directly

balance is not a property of the returned object, so account.balance is undefined. The only way to change it is through deposit and withdraw.

Debouncing a search input

Debouncing delays an action until input has paused for a set time. It is what keeps a search box from firing a request on every keystroke:

function debounce(func, delay) {
  let timer; // remembered between calls
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => func(...args), delay);
  };
}

const searchInput = document.getElementById("search");
const handleSearch = debounce((e) => {
  fetch(`/api/search?q=${e.target.value}`);
}, 300);

searchInput.addEventListener("input", handleSearch);

The key detail is where timer is declared: once, inside debounce, outside the returned function. Every keystroke calls the returned function, which clears the pending timeout and schedules a new one, so a request is sent only after 300 ms with no typing. If timer were declared inside the returned function, each call would get a fresh variable, the previous timeout could never be cancelled, and every keystroke would still produce a request.

In a React component the same logic applies with one extra step: the debounced function must be created once and kept stable (for example with useMemo or a ref), otherwise each render builds a new closure with a new timer and debouncing silently stops working.

A simple request limiter

The same technique can cap how often an endpoint is served:

function createRateLimiter(maxRequests, windowMs) {
  let requestCount = 0;
  setInterval(() => (requestCount = 0), windowMs);

  return function () {
    requestCount++;
    return requestCount <= maxRequests;
  };
}

const rateLimiter = createRateLimiter(5, 60000); // 5 requests per minute

app.get("/api/data", (req, res) => {
  if (!rateLimiter()) {
    return res.status(429).send("Too many requests");
  }
  res.send("Here's your data");
});

requestCount is shared privately by every call to rateLimiter, and an interval resets it each window. This version is a fixed-window counter held in memory, so keep its limits in mind:

  • it counts all callers together, so one client can use up the budget for everyone; real limiters key the count by user, token or IP
  • the state lives in one process, so several server instances each allow the full limit
  • fixed windows allow bursts at the boundary, up to twice the limit in a short span
  • the setInterval is never cleared, which matters in tests or when limiters are created dynamically

Undo and redo history

A history manager keeps its stack and cursor private to each instance:

function createHistoryManager() {
  let history = [];
  let currentIndex = -1;

  return {
    save(state) {
      history = history.slice(0, currentIndex + 1);
      history.push(state);
      currentIndex++;
    },
    undo() {
      if (currentIndex > 0) currentIndex--;
      return history[currentIndex];
    },
    redo() {
      if (currentIndex < history.length - 1) currentIndex++;
      return history[currentIndex];
    }
  };
}

save first discards anything after the current position, so recording a new change after an undo removes the redo branch, which matches how most editors behave. undo and redo just move currentIndex within bounds and return the state at that point. Note that the stored states should be immutable snapshots; if you save an object and then mutate it, every history entry points to the same changed data.

Quick heuristics for spotting closures

  • Inner uses outer. A function defined inside another function that references the outer function's variables is a closure.
  • It captures a binding, not a live feed of new renders. In React, each render creates new bindings, so an old callback keeps the values from its own render. Strictly speaking, a closure holds the variable itself, so if that same variable is reassigned later, as count is in the counter factory, the closure sees the new value.
  • A returned function is a strong hint. When a function returns another function, a closure is almost always involved.

The var-in-a-loop trap

Loop variables show the binding rule most clearly:

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 1000);
}
// Logs: 3, 3, 3 — var is shared across the whole loop

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 1000);
}
// Logs: 0, 1, 2 — let creates a new i on every iteration

With var, there is a single i for the whole function, and by the time the timeouts run the loop has already set it to 3, so all three callbacks print 3. With let, each iteration gets its own binding of i, and each callback closes over a different one, printing 0, 1 and 2.

Wrapping up

Closures are the mechanism behind private state, debounced inputs, request limiters, undo stacks and custom hooks, as well as many stale-state bugs. Two questions resolve most confusion: which variable did this function capture, and has that variable been replaced by a newer one since? In plain JavaScript factories, the captured variable is shared and updated in place. In React, every render brings new variables, so any callback that outlives its render sees an older world unless you deliberately give it a way to read the current one.