Engrain by ByteRush

RCT.04.03 Mid-level· 11 minCore — asked often

Stale closures, dependencies and effect events

Why an interval or listener created in an effect keeps seeing old state, and three correct fixes: functional updates, refs for the latest value, and useEffectEvent. Plus why you should never silence the dependency lint rule.

Comes up in: React, Coding

Learn#

Pooja’s timer shows 1 and stops counting, although the interval keeps firing. She used setCount(count + 1) inside an effect with an empty dependency array.

A stale closure is a function that remembers values from an old render (JS.02.03). Each render has its own count. An interval created on the first render only ever sees that render’s count:

import { GlobalRegistrator } from "@happy-dom/global-registrator";
GlobalRegistrator.register(); // a DOM for Node; in a browser you already have one
const { createElement: h, useEffect, useState } = await import("react");
const { createRoot } = await import("react-dom/client");

function Stale() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => setCount(count + 1), 100); // count is always 0 here
    return () => clearInterval(id);
  }, []); // the lint rule would warn: count is missing
  return h("p", null, `stale: ${count}`);
}
function Fixed() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => setCount((c) => c + 1), 100); // reads the latest value
    return () => clearInterval(id);
  }, []);
  return h("p", null, `fixed: ${count}`);
}
const box = document.createElement("div");
const root = createRoot(box);
root.render(h("div", null, h(Stale), h(Fixed)));
await new Promise((r) => setTimeout(r, 450));
console.log([...box.querySelectorAll("p")].map((p) => p.textContent).join(" | "));
root.unmount();
This example uses Node.js, so it runs in our checks rather than in your browser.

Output (verified)

stale: 1 | fixed: 4

The functional update setCount(c => c + 1) does not need count at all, so nothing can go stale.

An interval stuck on render 1's countStep 1 of 4

State

  • render 1: count = 0
  • interval callback sees count = 0

Page

    empty

    Render 1: count is 0. The effect (deps []) creates an interval whose callback closes over this render's count, 0.

    Read the steps as text
    1. Render 1: count is 0. The effect (deps []) creates an interval whose callback closes over this render's count, 0.
    2. Tick: setCount(count + 1) sets 0 + 1 = 1. Render 2 shows 1, but the effect does not re-run, so the old callback stays.
    3. Every later tick sets 0 + 1 = 1 again. The counter is stuck: a stale closure.
    4. setCount(c => c + 1) asks React for the current value each time, so the same old callback works.

    Predict#

    Predict first

    You add count to the dependency array of the interval effect. Does the counter work?

    Interview angle#

    Say it like this — about 30 seconds

    “A stale closure happens when a long-lived callback, such as an interval or event listener created in an effect, keeps values from the render that created it. The fix depends on the case. If I only need the previous state, I use a functional update. If the value must stay a dependency, I list it and let the effect re-sync. If some logic should read the latest props without restarting the effect, I use useEffectEvent, or a ref updated each render. I never silence the exhaustive-deps rule.”

    Stays on your device. Nothing is uploaded.
    Follow-up What is a stale closure in React? Show an example and fix it.
    setInterval(() => setCount(count + 1)) with [] deps sees count 0 forever; fix with setCount(c => c + 1).
    Follow-up What is useEffectEvent for?
    Logic inside an effect that must read the latest props or state but should not cause the effect to re-run, such as logging the current theme when a connection opens.
    Follow-up How does a ref fix stale values?
    Write the latest value to ref.current in an effect each render; the long-lived callback reads ref.current.

    Practice#

    Guided

    Build the core idea of useEffectEvent: a stable function that always calls the latest version.

    Done when

    • call always runs the most recent function given to update.
    • A callback captured once (like an interval) still sees the newest values.
    • Arguments and return values pass through.
    Your solution
    Ctrl/⌘ + Enter

    Remember#

    Try to answer each card before you open it. Then add them to your reviews.

    What is a stale closure?

    A callback that keeps values from the render that created it.

    Best fix for setCount(count + 1) in an interval?

    The functional update setCount(c => c + 1).

    What does useEffectEvent give you?

    A function that reads the latest values but is not an effect dependency.

    Should you disable the exhaustive-deps lint rule?

    No. It points at stale-closure bugs.

    Go deeper

    Four layers. Stop at the one your interview needs; each ends with a quick check.

    The core ideaEvery round

    Every function created during a render closes over that render’s props and state. Event handlers are re-created each render, so they are fresh; callbacks created once in an effect are not.

    Check yourself: Why does the interval callback see count 0 forever?

    It was created in render 1 and the effect never re-ran, so it closes over render 1's count.

    How it works underneathSenior rounds
    The “latest ref” pattern: const latest = useRef(value); useEffect(() => { latest.current = value; }); then read latest.current in the long-lived callback (RCT.03.03).
    Check yourself: Where should you write ref.current = value?

    In an effect (or event handler), not during render.

    Edge cases and trapsWhere people slip

    An effect event from useEffectEvent must only be called from inside effects, never during render or passed to children Source: React: useEffectEvent.
    A reactive value used directly in the effect still belongs in its dependencies.

    Check yourself: Can you pass an effect event to a child component?

    No. Call effect events only from inside effects.

    In productionStaff-level answers
    Keep react-hooks/exhaustive-deps on. If it asks for a dependency you do not want, change the code: move logic into an effect event, use an updater, or move the object creation inside the effect Source: React: Removing effect dependencies.
    Check yourself: What does the exhaustive-deps rule protect you from?

    Effects and memos that use values they do not list, which go stale.

    Finished the five steps? Mark it, and its cards join your reviews.

    Last checked 2026-11-01. Spot a mistake? Use “Report an error” below — fixes are listed on the corrections page.

    Interview questions this answers

    • What is a stale closure in React? Show an example and fix it.
    • What is useEffectEvent for?