React 19.2 useEffectEvent: Kill Stale Closures in Effects — ContentBuffer guide

React 19.2 useEffectEvent: Kill Stale Closures in Effects

K
Kodetra Technologies··8 min read Intermediate

Summary

Master useEffectEvent in React 19.2 to separate non-reactive logic from your Effects.

If you have ever stared at a React Effect that logs the wrong value or tears down a perfectly good websocket every time the user toggles dark mode, you have met the stale closure. useEffectEvent — stable in React 19.2 — is the official fix. It lets you read the latest props and state from inside an Effect without making them dependencies.

This guide walks through the problem, the workarounds people have been using for years (refs, ESLint disables, manual sync), and how useEffectEvent replaces all of them. By the end you will know exactly when to reach for it, when not to, and how to refactor an Effect that is fighting you today.


Prerequisites

  • React 19.2 or newer (or Next.js 16, which ships with the matching React canary).
  • Comfortable with useEffect and the dependency array.
  • A passing familiarity with closures in JavaScript.

The stale closure, in 12 lines

Here is the canonical bug. We want to log the current count once a second:

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

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count); // always 0
    }, 1000);
    return () => clearInterval(id);
  }, []); // empty deps

  return <button onClick={() => setCount(c => c + 1)}>+1</button>;
}

Click the button five times and the console prints 0, 0, 0, 0, 0. The interval was set up on the first render, when count was 0, and the closure captured that value forever. Adding [count] to the deps fixes the log but creates a new interval every click — almost certainly not what you want.

Real apps hit this constantly: an analytics call inside an Effect that reads the latest user, a websocket subscription that wants the latest filters, a chat client that should pick up the latest theme when it shows a toast. The reactive part (room id, user id, connection url) is different from the non-reactive part ("whatever the value happens to be when this fires"). Until 19.2, the language did not have a clean way to say so.


The workarounds you can stop using

Three patterns dominated the pre-19.2 codebase. Each works, none feels good.

1. The ref pin

const countRef = useRef(count);
useEffect(() => { countRef.current = count; });

useEffect(() => {
  const id = setInterval(() => console.log(countRef.current), 1000);
  return () => clearInterval(id);
}, []);

Two Effects, a ref, and a synchronization step. It works but every reader has to mentally track the indirection.

2. The ESLint disable

Sprinkling // eslint-disable-next-line react-hooks/exhaustive-deps ships fast and breaks later. The next person who edits the Effect introduces a new reactive value, forgets the disabled rule is hiding it, and now the Effect silently misses updates.

3. The kitchen-sink dependency array

Listing everything fixes the closure but reruns the Effect on every unrelated change. A websocket connection that recreates itself when the theme toggles is a UX (and bandwidth) bug.


Enter useEffectEvent

useEffectEvent wraps a function so it always sees the latest props and state — but it is not reactive. Calling it from an Effect does not force the Effect to re-run, and the wrapped function does not belong in the dependency array. You import it from react:

import { useEffect, useEffectEvent, useState } from 'react';

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

  const onTick = useEffectEvent(() => {
    console.log(count); // latest count, always
  });

  useEffect(() => {
    const id = setInterval(onTick, 1000);
    return () => clearInterval(id);
  }, []); // onTick is NOT a dep

  return <button onClick={() => setCount(c => c + 1)}>+1</button>;
}

Click five times and the console prints 1, 2, 3, 4, 5. The interval is set up exactly once. The fact that onTick reads count is its problem, not the Effect's.


A real refactor: chat client that respects theme

Here is a more realistic Effect. It opens a chat connection for roomId and shows a toast when a message arrives. The toast should match the current theme, but the connection itself must not reset every time the user toggles light and dark.

Before

function ChatRoom({ roomId, theme }) {
  useEffect(() => {
    const conn = createConnection(roomId);
    conn.on('message', (msg) => {
      showToast(msg, { theme }); // closes over theme
    });
    conn.connect();
    return () => conn.disconnect();
  }, [roomId, theme]); // theme reconnects: bad
}

Drop theme from the deps and the toast color drifts. Keep it and you reconnect every theme toggle. This is exactly the situation useEffectEvent was designed for.

After

function ChatRoom({ roomId, theme }) {
  const onMessage = useEffectEvent((msg) => {
    showToast(msg, { theme }); // always latest theme
  });

  useEffect(() => {
    const conn = createConnection(roomId);
    conn.on('message', onMessage);
    conn.connect();
    return () => conn.disconnect();
  }, [roomId]); // theme is gone, on purpose
}

Now the connection only resets when roomId changes. The toast picks up whatever theme is current at the moment a message lands. The dependency array tells the truth: only roomId is a reactive input to the connection.


A second example: analytics that always knows the user

Analytics calls are the other place this hook earns its keep. Imagine a page-view ping that should fire once per route, attaching the latest user id and feature flags. The route is reactive — a change means we want a new ping. The user id and flag set are non-reactive: a change in either should not fire a duplicate ping, but the next ping should pick them up.

function PageTracker({ pathname, user, flags }) {
  const sendPageView = useEffectEvent(() => {
    analytics.track('page_view', {
      pathname,
      userId: user.id,
      flags: Object.keys(flags).filter(k => flags[k]),
    });
  });

  useEffect(() => {
    sendPageView();
  }, [pathname]); // only the path is reactive

  return null;
}

Without useEffectEvent you had two bad options here. List user and flags in the deps and a flag toggle fires a phantom page view. Leave them out and your dashboards show stale flag values for users who toggle settings mid-session. Now the Effect describes the reality: the page only changes when the path changes, and the metadata is whatever it is at that moment.


The mental model

Read reactive as "this Effect should run again when this value changes" and non-reactive as "this Effect should see the latest value, but a change alone is not a reason to run again." Reactive values go in the dependency array. Non-reactive reads go inside a useEffectEvent.

If you can describe a value with the phrase "whatever it happens to be when the callback fires," it is non-reactive. The classic examples: the current user for an analytics ping, the current filters for a logged search, the current locale for a formatted toast.


How it works under the hood

An Effect Event is, mechanically, a function that React holds onto in a ref and swaps the implementation of on every render. The reference you get back is stable — the same identity render after render — but the body it points to is always the latest one. That is why it can read the latest props and state without participating in the dependency graph: the function React installs after each commit closes over the new values, while the wrapper your Effect captured points at the same slot.

This also explains the in-Effect-only restriction. During render, React has not yet committed the new state, so the slot still holds the previous body. Calling an Effect Event then would give you the old values, which is the opposite of the contract. The render-time error is there to keep that contract honest.

If you have written your own version of this in the past — a useLatest or useEvent hook backed by a ref and a layout effect — the official version does the same job, plus it is recognized by the lint rule and the React DevTools. The custom hook still works, but the official one composes correctly with concurrent rendering and Strict Mode's double-invoke.


When not to reach for this hook

Not every dependency-array friction is a stale closure. A few cases look similar but want a different fix.

  • The value really is reactive. If a change should re-run the Effect, it belongs in the deps. useEffectEvent is for the values that should not.
  • The work belongs in an event handler. If a button click triggers a fetch and you are reaching for an Effect to read the latest state, the fetch probably belongs directly in onClick. No Effect, no closure problem.
  • You want to memoize a computed value. That is what useMemo is for. Effect Events return a callback, not a value, and they cannot run during render.
  • You need a stable callback for a child. Children re-render on prop identity, and Effect Events are not legal to pass down. Wrap with useCallback or hoist the function.

Pitfalls and gotchas

  • Only call Effect Events from Effects. Calling them from event handlers, render, or passing them to children is unsupported and will warn. If you need a stable callback for a child component, use useCallback or pass the wrapped function through a ref.
  • Do not put them in the dependency array. The whole point is that they do not need to be there. The lint rule for useEffectEvent knows this; older configs may complain — upgrade eslint-plugin-react-hooks.
  • They are not setState replacements. If you find yourself calling an Effect Event during render to compute a value, you want a normal function or useMemo, not this hook.
  • They cannot run during render. Effect Events are guaranteed to see the latest committed state, which means they cannot fire while React is still figuring out what that state is. Calling one in render throws.
  • Refactor first, wrap second. Sometimes the right fix is to move the non-reactive read out of the Effect entirely — into the event handler that actually triggered the work. Reach for useEffectEvent when the read genuinely belongs inside the Effect's lifecycle.

Quick reference

SituationUseWhy
Effect should re-run when the value changesDependency arrayThat is what reactive means
Effect should read the value but not re-run on changesuseEffectEventLatest value, no re-run
Stable callback passed to a child componentuseCallbackuseEffectEvent is Effect-only
Mutable holder shared across rendersuseRefStill the right tool for non-state mutables
Memoize a derived value for renderuseMemoEffect Events do not run during render

Next steps

  • Audit any // eslint-disable-next-line react-hooks/exhaustive-deps in your codebase — most of them are useEffectEvent candidates.
  • Pair with the new <Activity /> component for screens that should keep state when hidden. Effect Events fire on the latest committed state even after an Activity becomes visible again.
  • Read the official RFC and API reference at react.dev for the exact semantics around Strict Mode and concurrent rendering.

Stale closures used to be a rite of passage. With useEffectEvent stable in 19.2, that part of the React learning curve is finally flatter. Pick one Effect in your app today, find the value that does not belong in the dependency array, and move it into an Effect Event. The diff will be small, the bug surface smaller.

Comments

Subscribe to join the conversation...

Be the first to comment