Skip to content
Back to Blog
ReactHooksuseEffectuseCallbackuseEffectEventClosures

Stale Closures in React: Every Render Is a Snapshot Your Callbacks Can't See

You'll learn why stale closures happen in React (it's not a React bug), how to spot them in useEffect, useCallback, and async code, and which fix to reach for: dependencies, functional setState, refs, or useEffectEvent.

21 min read

TL;DR: A stale closure is a function that captured values from an old render and never got updated. React doesn't cause this. JavaScript closures do, and hooks make the timing visible. Every render is a snapshot. If your effect, timer, or memoized callback was created once and reads state from that moment, it will keep reading that moment forever. Fix it by re-syncing (dependency arrays), bypassing the closure (functional setState), or reading through a ref or useEffectEvent.


You set a setInterval inside useEffect with an empty dependency array. The counter on screen climbs to 47. The interval keeps logging 0. You add count to the deps. Now the interval restarts every second and the counter goes haywire. You remove count from deps, wrap it in a ref, sync the ref in another effect, and wonder why a language feature from 1995 needs three hooks to increment a number.

This is the stale closure problem. It's widely misunderstood because people treat it like a React bug when it's really a JavaScript behavior that React's rendering model makes impossible to ignore.

The misconception: "React hooks are broken" or "useEffect doesn't see updated state." Neither is true. React re-renders with fresh values every time. The function inside your effect was created during an earlier render and never replaced. It's still looking at that render's snapshot.

Once you internalize that, the fixes stop feeling like hacks and start feeling like choices.


What a Stale Closure Actually Is

A closure is a function that remembers variables from the scope where it was created. That's a feature. Stale means the scope is old.

function makeGreeter(name) {
  return function greet() {
    console.log(`Hello, ${name}`);
  };
}
 
const greetAlice = makeGreeter('Alice');
greetAlice(); // "Hello, Alice"
 
// name inside greet is frozen at creation time.
// Nothing updates it unless you create a new function.

React adds one twist: your component function runs again on every state change. Each run creates a new scope with new count, new query, new theme. But the setInterval callback you registered two minutes ago still closes over the count from the render when the effect ran.

function Counter() {
  const [count, setCount] = useState(0);
 
  useEffect(() => {
    const id = setInterval(() => {
      // This `count` is whatever it was when THIS effect ran
      console.log('interval sees:', count);
    }, 1000);
    return () => clearInterval(id);
  }, []); // effect runs once → closure frozen forever
 
  return (
    <button onClick={() => setCount(c => c + 1)}>
      {count}
    </button>
  );
}

The UI shows 47. The interval logs 0. Both are "correct" from their respective points of view. The button's onClick handler is recreated every render, so it always sees fresh state. The interval callback was created once and wasn't.

That's the whole bug. Everything else is a variation.


The Mental Model People Get Wrong

Three beliefs cause most of the confusion:

"State is a variable that updates in place." It's not. count in render 1 and count in render 47 are different bindings in different function invocations. React keeps the latest value internally and hands you the current one each render. Old closures still point at old bindings.

"useEffect runs after every render." Only when its dependencies change (or on mount with []). An effect with [] runs once. The closure inside it is from that one render.

"Event handlers have stale closure problems too." Usually no. When you click a button, React calls the handler from the latest render. You get a fresh closure at interaction time. Stale closures bite in callbacks that outlive the render: timers, subscriptions, memoized functions passed to children, async continuations.

function Search() {
  const [query, setQuery] = useState('');
 
  // ✅ Fresh on every click, recreated each render
  const handleClick = () => {
    console.log(query);
  };
 
  // ❌ Stale: created once, lives until unmount
  useEffect(() => {
    const id = setInterval(() => {
      console.log(query); // frozen at mount
    }, 1000);
    return () => clearInterval(id);
  }, []);
 
  return <button onClick={handleClick}>Log query</button>;
}

Click the button after typing. handleClick logs the current query. The interval does not.


Pattern 1: The Timer That Can't Count

The canonical example. Broken three different ways, fixed three different ways.

Broken: empty deps

function Counter() {
  const [count, setCount] = useState(0);
 
  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // always count + 1 where count === 0
    }, 1000);
    return () => clearInterval(id);
  }, []);
 
  return <p>{count}</p>;
}

setCount(0 + 1) runs every second. Counter stuck at 1.

Fix A: functional setState (best when next state depends only on previous)

useEffect(() => {
  const id = setInterval(() => {
    setCount(prev => prev + 1); // React gives you the real latest value
  }, 1000);
  return () => clearInterval(id);
}, []);

No count in the closure. React's updater function receives the committed state directly. This is the cleanest fix when you're only deriving the next state from the previous one.

Fix B: add count to deps (when you need the actual value, not just prev + 1)

useEffect(() => {
  const id = setInterval(() => {
    setCount(count + increment); // needs both count AND increment
  }, 1000);
  return () => clearInterval(id);
}, [count, increment]); // effect restarts every tick, bad for intervals

This works logically but restarts the interval on every count change. For a 1-second interval that increments by 1, you reset the timer every second. Sometimes that's fine. Often it's not.

Fix C: ref to read latest without restarting

function Counter() {
  const [count, setCount] = useState(0);
  const [increment, setIncrement] = useState(1);
  const countRef = useRef(count);
  const incrementRef = useRef(increment);
 
  // ponytail: syncs on every render; upgrade path is useEffectEvent in React 19.2+
  countRef.current = count;
  incrementRef.current = increment;
 
  useEffect(() => {
    const id = setInterval(() => {
      setCount(countRef.current + incrementRef.current);
    }, 1000);
    return () => clearInterval(id);
  }, []); // interval never restarts, always reads fresh values
 
  return (/* ... */);
}

Writing to ref.current during render (not in an effect) is the pattern React docs use for keeping refs in sync. The interval stays stable. The refs always hold the latest values.


Pattern 2: useCallback With Empty Deps

This is where performance optimization and stale closures collide. You memoized the callback to stop child re-renders. You created a time capsule.

function Parent() {
  const [count, setCount] = useState(0);
 
  const increment = useCallback(() => {
    setCount(count + 1); // count is always 0 inside this closure
  }, []); // "stable" and wrong
 
  return <MemoizedChild onIncrement={increment} />;
}
 
const MemoizedChild = React.memo(function Child({ onIncrement }) {
  return <button onClick={onIncrement}>+</button>;
});

Click the button ten times. Count stays at 1.

Fix: functional update in a stable callback

const increment = useCallback(() => {
  setCount(prev => prev + 1);
}, []); // stable AND correct

Fix: include the dependency (stable only if child can handle it)

const increment = useCallback(() => {
  setCount(count + 1);
}, [count]); // new function every count change → MemoizedChild re-renders anyway

If count is in the deps, useCallback recreates the function every time count changes. React.memo on the child sees a new prop and re-renders. You traded a stale closure for a memoization that does nothing.

The rule: useCallback with [] only works if the callback never reads reactive values, or reads them through functional updates, refs, or external stores with stable subscriptions.


Pattern 3: The Async Fetch That Applies Old Results

Stale closures and race conditions often show up together.

function UserSearch() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<User[]>([]);
 
  useEffect(() => {
    if (!query) return;
 
    fetch(`/api/users?q=${query}`)
      .then(res => res.json())
      .then(data => {
        // If user typed "react" then "vue", both fetches are in flight.
        // Whichever resolves LAST wins. Might be the stale "react" response.
        setResults(data);
      });
  }, [query]);
 
  return (/* ... */);
}

The dependency array is correct here. The effect re-runs when query changes. But the old fetch's .then is still a closure that calls setResults. It's not stale in the "wrong count" sense. It's stale in the "this response is for an old query" sense.

Fix: AbortController (browser-native, cancel in-flight requests on cleanup)

useEffect(() => {
  if (!query) return;
 
  const controller = new AbortController();
 
  fetch(`/api/users?q=${query}`, { signal: controller.signal })
    .then(res => res.json())
    .then(data => setResults(data))
    .catch(err => {
      if (err.name !== 'AbortError') throw err;
    });
 
  return () => controller.abort(); // cancel in-flight request on query change
}, [query]);

Fix: request ID guard (when you can't abort)

useEffect(() => {
  if (!query) return;
 
  let ignore = false;
 
  fetch(`/api/users?q=${query}`)
    .then(res => res.json())
    .then(data => {
      if (!ignore) setResults(data);
    });
 
  return () => { ignore = true; };
}, [query]);

The closure over ignore is fine because cleanup flips the flag. The .then still runs, but the guard prevents the stale write.


Pattern 4: Event Listeners in Effects

Same shape as the timer. Register once, read stale values forever.

function DragTracker() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [enabled, setEnabled] = useState(true);
 
  useEffect(() => {
    function onMove(e: PointerEvent) {
      if (enabled) {
        setPosition({ x: e.clientX, y: e.clientY });
      }
    }
 
    window.addEventListener('pointermove', onMove);
    return () => window.removeEventListener('pointermove', onMove);
  }, []); // enabled is frozen at initial value
 
  return (/* checkbox for enabled, dot at position */);
}

Uncheck "enabled." The dot keeps following your cursor. enabled inside onMove is still true.

Fix: ref for the gate flag

const enabledRef = useRef(enabled);
enabledRef.current = enabled;
 
useEffect(() => {
  function onMove(e: PointerEvent) {
    if (enabledRef.current) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
  }
 
  window.addEventListener('pointermove', onMove);
  return () => window.removeEventListener('pointermove', onMove);
}, []);

Fix: add enabled to deps (re-register listener)

useEffect(() => {
  function onMove(e: PointerEvent) {
    if (enabled) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
  }
 
  window.addEventListener('pointermove', onMove);
  return () => window.removeEventListener('pointermove', onMove);
}, [enabled]); // tears down and re-adds listener when enabled changes

Re-registering is correct and often cheap. Don't reach for a ref just to avoid removing a listener unless profiling shows it's a problem.


Pattern 5: useEffect With Missing Dependencies

React's linter exists because this bug is silent.

function ChatRoom({ roomId }: { roomId: string }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');
 
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, []); // 🔴 roomId and serverUrl are stale after user changes them
}

User switches rooms. Effect never re-runs. Still connected to the first room.

Fix: list every reactive value you read

useEffect(() => {
  const connection = createConnection(serverUrl, roomId);
  connection.connect();
  return () => connection.disconnect();
}, [serverUrl, roomId]);

React docs are explicit: you don't "choose" your dependencies. If your effect reads it, it goes in the array. The linter isn't being pedantic. It's catching a real desync between your effect and your UI.


Pattern 6: The Debounced Search That Searches Old Queries

function Search() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
 
  const debouncedSearch = useCallback(
    debounce((q: string) => {
      fetch(`/api/search?q=${q}`)
        .then(r => r.json())
        .then(setResults);
    }, 300),
    [] // debounce instance is stable, but...
  );
 
  useEffect(() => {
    debouncedSearch(query);
  }, [query, debouncedSearch]);
}

This one is actually fine IF debouncedSearch is stable and you pass query as an argument each call. The stale closure risk is inside the debounce implementation itself if it captures state:

// ❌ debounce closure captures `query` from render
const debouncedSearch = useCallback(
  debounce(() => {
    fetch(`/api/search?q=${query}`) // frozen query from when debounce was created
      .then(r => r.json())
      .then(setResults);
  }, 300),
  []
);

Fix: always pass values as arguments, never close over them

const debouncedSearch = useRef(
  debounce((q: string) => {
    fetch(`/api/search?q=${q}`)
      .then(r => r.json())
      .then(setResults);
  }, 300)
).current;
 
useEffect(() => {
  debouncedSearch(query);
}, [query]);

Or recreate the debounced function when the search logic changes:

const debouncedSearch = useMemo(
  () => debounce((q: string) => {
    fetch(`/api/search?q=${q}`).then(r => r.json()).then(setResults);
  }, 300),
  [/* deps of the search logic, e.g. filters */]
);
 
useEffect(() => {
  debouncedSearch(query);
  return () => debouncedSearch.cancel();
}, [query, debouncedSearch]);

Pattern 7: Custom Hooks That Leak Stale Values

Custom hooks are just functions. They close over render scope like everything else.

// ❌ consumers get a stale toggle forever
function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
 
  const toggle = useCallback(() => {
    setOn(on => !on); // functional update, actually fine
  }, []);
 
  const turnOn = useCallback(() => {
    setOn(true); // fine, doesn't read on
  }, []);
 
  const logState = useCallback(() => {
    console.log('current:', on); // ❌ stale on
  }, []);
 
  return { on, toggle, turnOn, logState };
}

toggle is safe because it uses the functional form. logState is broken because it reads on with [] deps.

Fix: ref inside the custom hook

function useLatest<T>(value: T) {
  const ref = useRef(value);
  ref.current = value;
  return ref;
}
 
function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const onRef = useLatest(on);
 
  const logState = useCallback(() => {
    console.log('current:', onRef.current);
  }, []);
 
  const toggle = useCallback(() => setOn(v => !v), []);
 
  return { on, toggle, logState };
}

Any custom hook that returns a stable callback reading props or state should use functional updates, refs, or useEffectEvent internally.


Pattern 8: useMemo Closures

Less common, same mechanics.

function FilteredList({ items, minPrice }: Props) {
  const [sortAsc, setSortAsc] = useState(true);
 
  const sortedItems = useMemo(() => {
    return items
      .filter(item => item.price >= minPrice)
      .sort((a, b) => sortAsc ? a.price - b.price : b.price - a.price);
  }, [items, minPrice]); // 🔴 missing sortAsc, list doesn't re-sort on toggle
}

useMemo caches a value and only recomputes when deps change. Missing sortAsc means the sort direction is frozen.

Fix: complete deps

const sortedItems = useMemo(() => {
  return items
    .filter(item => item.price >= minPrice)
    .sort((a, b) => sortAsc ? a.price - b.price : b.price - a.price);
}, [items, minPrice, sortAsc]);

Pattern 9: WebSocket and Subscription Handlers

Long-lived connections are stale closure magnets.

function PriceTicker({ symbol }: { symbol: string }) {
  const [price, setPrice] = useState<number | null>(null);
  const [showAlert, setShowAlert] = useState(false);
 
  useEffect(() => {
    const ws = new WebSocket(`wss://api.example.com/prices`);
 
    ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      setPrice(data[symbol]);
 
      if (showAlert && data[symbol] > 100) {
        notify('Price above 100!');
      }
    };
 
    return () => ws.close();
  }, [symbol]); // showAlert is stale when user toggles it
}

symbol is in deps, so reconnecting on symbol change is correct. But showAlert is read inside onmessage without being a dep. Toggling the alert checkbox does nothing until you reconnect.

Fix: ref for the non-reactive toggle

const showAlertRef = useRef(showAlert);
showAlertRef.current = showAlert;
 
useEffect(() => {
  const ws = new WebSocket(`wss://api.example.com/prices`);
 
  ws.onmessage = (event) => {
    const data = JSON.parse(event.data);
    setPrice(data[symbol]);
 
    if (showAlertRef.current && data[symbol] > 100) {
      notify('Price above 100!');
    }
  };
 
  return () => ws.close();
}, [symbol]);

You don't want to reconnect the WebSocket when showAlert changes. You want the handler to read the latest flag. That's the ref use case.


Pattern 10: useEffectEvent (React 19.2+)

Before useEffectEvent shipped as a stable API in React 19.2, the ref-sync pattern was the standard answer for "this effect should run once, but the callback needs fresh values." useEffectEvent formalizes it.

import { useEffect, useEffectEvent, useState } from 'react';
 
function ChatRoom({ roomId, muted }: { roomId: string; muted: boolean }) {
  const onConnected = useEffectEvent(() => {
    if (!muted) {
      showNotification(`Connected to ${roomId}`);
    }
  });
 
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on('connected', () => onConnected());
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // muted is NOT a dep. Reconnecting on mute toggle would be wrong.
}

onConnected always sees the latest muted and roomId when it's called, but it doesn't cause the effect to re-run when they change.

Timer with useEffectEvent

function Timer() {
  const [count, setCount] = useState(0);
  const [increment, setIncrement] = useState(1);
 
  const onTick = useEffectEvent(() => {
    setCount(count + increment);
  });
 
  useEffect(() => {
    const id = setInterval(onTick, 1000);
    return () => clearInterval(id);
  }, []);
 
  return (/* UI to change increment */);
}

Change increment while the timer runs. The counter immediately uses the new step size. The interval never restarts.

Reusable useInterval hook

function useInterval(callback: () => void, delay: number | null) {
  const onTick = useEffectEvent(callback);
 
  useEffect(() => {
    if (delay === null) return;
    const id = setInterval(onTick, delay);
    return () => clearInterval(id);
  }, [delay]);
}
 
// Usage: increment changes without resetting the interval
function Counter({ step }: { step: number }) {
  const [count, setCount] = useState(0);
 
  useInterval(() => {
    setCount(c => c + step);
  }, 1000);
 
  return <p>{count}</p>;
}

What useEffectEvent is NOT for

// ❌ Don't use it to hide deps that SHOULD re-trigger the effect
const logVisit = useEffectEvent(() => {
  analytics.log(pageUrl);
});
 
useEffect(() => {
  logVisit();
}, []); // pageUrl changes, effect doesn't re-run, visits aren't logged

If a value changing should cause your effect to re-synchronize (reconnect, refetch, re-subscribe), it belongs in the dependency array. useEffectEvent is for logic inside an effect that should not be reactive. Notifications, logging, reading toggle flags in callbacks. Not for skipping deps you're lazy about.

Also: Effect Events can only be called from effects. Don't pass them to children or use them in onClick.


Pattern 11: Ref Pattern vs useLayoutEffect Sync

The pre-React-19 pattern you'll still see in codebases:

function ChatRoom({ roomId, theme }: { roomId: string; theme: string }) {
  const themeRef = useRef(theme);
 
  useLayoutEffect(() => {
    themeRef.current = theme;
  }, [theme]);
 
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on('connected', () => {
      showNotification('Connected!', themeRef.current);
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);
}

useLayoutEffect guarantees the ref is updated before paint, which matters if the effect fires synchronously during the same commit. For most cases, assigning ref.current = value during render works and is what React's own docs recommend now.

// Simpler, usually sufficient
const themeRef = useRef(theme);
themeRef.current = theme;

Pattern 12: Stale Closures in useReducer

Less talked about, same rules.

function TodoList() {
  const [todos, dispatch] = useReducer(reducer, []);
  const [filter, setFilter] = useState<'all' | 'done'>('all');
 
  const markAllDone = useCallback(() => {
    // ❌ `filter` is stale, only marks items matching the filter at creation time
    todos
      .filter(t => filter === 'all' || t.done)
      .forEach(t => dispatch({ type: 'MARK_DONE', id: t.id }));
  }, []); // missing todos and filter
 
  return (/* ... */);
}

Fix: pass intent through the action, not through closure

const markAllDone = useCallback(() => {
  dispatch({ type: 'MARK_ALL_DONE', filter });
}, [filter]); // or dispatch({ type: 'MARK_ALL_DONE' }) and let reducer handle all

Reducer actions should carry the data they need. Don't rely on the dispatching closure to close over state that might be old.


Pattern 13: Context + Stable Callback

function DataProvider({ children }: { children: React.ReactNode }) {
  const [data, setData] = useState<Item[]>([]);
  const [filter, setFilter] = useState('');
 
  const api = useMemo(() => ({
    addItem: (item: Item) => {
      // ❌ closes over `data` from when useMemo last ran
      setData([...data, item]);
    },
    setFilter,
  }), []); // empty deps = data is always initial []
 
  return (
    <DataContext.Provider value={api}>
      {children}
    </DataContext.Provider>
  );
}

Fix: functional update in the stable API

const api = useMemo(() => ({
  addItem: (item: Item) => {
    setData(prev => [...prev, item]);
  },
  setFilter,
}), []); // setData and setFilter are stable from useState

Pattern 14: The isMounted Guard (Stale in a Different Way)

function Profile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
 
  useEffect(() => {
    let isMounted = true;
 
    fetchUser(userId).then(data => {
      if (isMounted) setUser(data);
    });
 
    return () => { isMounted = false; };
  }, [userId]);
}

This isn't a stale state read. The closure over isMounted works because cleanup mutates it. But watch the footgun: if you capture userId in a nested async function without it being a dep of an outer effect, you get the classic stale ID problem.

// ❌ outer effect runs once, inner fetches always use initial userId
useEffect(() => {
  const controller = new AbortController();
 
  async function load() {
    const data = await fetchUser(userId, { signal: controller.signal });
    setUser(data);
  }
 
  load();
  return () => controller.abort();
}, []); // userId missing

Pattern 15: Strict Mode Makes It Look Worse

In development, React 18+ runs setup → cleanup → setup to surface missing cleanups. Your interval fires twice on mount. If you're logging closures, you'll see two registrations and think something is doubly stale.

useEffect(() => {
  console.log('setup, count is', count);
  const id = setInterval(() => console.log('tick, count is', count), 1000);
  return () => {
    console.log('cleanup');
    clearInterval(id);
  };
}, []);

Dev console: setup (0), cleanup, setup (0), tick (0), tick (0)... Production: setup (0), tick (0), tick (0)...

The stale value isn't caused by Strict Mode. But Strict Mode exposes effects that don't clean up properly. If your interval isn't cleared, you get duplicate timers and truly bizarre behavior.


The Decision Tree

When you suspect a stale closure, ask these questions in order:

1. Is the callback recreated every render (event handler)?
   → Probably fine. Handlers are fresh at interaction time.
 
2. Does the callback outlive the render (timer, listener, memoized prop, async)?
   → Stale closure territory. Continue.
 
3. Are you only computing next state from previous state?
   → Functional setState: setX(prev => ...)
 
4. Should the effect re-synchronize when this value changes?
   (reconnect, refetch, re-subscribe)
   → Add to dependency array.
 
5. Should the effect stay running but read the latest value?
   (timer tick, listener callback, notification preference)
   → useEffectEvent (React 19.2+) or ref sync.
 
6. Are you passing a stable callback to a memoized child?
   → useCallback + functional setState, or ref for reads.

Quick Reference

SituationBroken patternFix
Interval incrementing statesetCount(count + 1) with []setCount(c => c + 1)
Interval reading multiple valuesMissing deps, can't restart timerRef sync or useEffectEvent
Memoized child callbackuseCallback(() => setX(x + 1), [])Functional update in callback
Effect connecting to external systemMissing roomId in depsAdd all read values to deps
Listener reading a toggle flag[] deps, reads enabledRef or useEffectEvent, or add enabled to deps
Async fetch raceNo abort/guard on query changeAbortController or ignore flag
Debounced functionCloses over query internallyPass query as argument
Custom hook stable callbackReads state with [] depsuseLatest ref or functional update
WebSocket handlerReads state not in effect depsRef for non-reconnecting values
Context stable APIsetData([...data, item]) in useMemo([])setData(prev => [...prev, item])

What I Got Wrong for Years

I used to think the exhaustive-deps ESLint rule was overzealous. I'd disable it, add a comment like "intentional," and ship. Then a chat room wouldn't switch when the user picked a new room, or a notification would fire with the wrong theme, or a React.memo child would call a callback that incremented to 1 and stopped.

The rule isn't pedantic. It's asking one question: does this effect need to re-synchronize when that value changes? If yes, add the dep. If no, you need a different mechanism to read the latest value (functional update, ref, useEffectEvent). There isn't a third option where you just hope JavaScript notices the variable changed.

Stale closures aren't a React footgun. They're what happens when a function outlives the render that created it. React just makes renders frequent enough that you'll hit it. Once you see every render as a snapshot, you stop fighting the model and start picking the right escape hatch.

The counter stuck at 1 isn't mysterious. It's a function from render zero, doing exactly what you told it to do. The fix is either give it a new function (deps), stop asking it what count is (functional setState), or hand it a mailbox that always has today's mail (ref or useEffectEvent).

Pick the one that matches whether your effect should re-run. That's the whole decision.