DEV Community

Cover image for React 19.2 useEffectEvent: Why It Won't Fix Your Deps Array
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

React 19.2 useEffectEvent: Why It Won't Fix Your Deps Array

Your chat room component reconnects every time someone flips the dark-mode switch. Messages in flight get dropped, the "typing…" indicator resets, and support tickets call it "random." The bug isn't the dark-mode toggle — it's that theme is sitting in a useEffect dependency array next to roomId, and React has no way to know that only one of those two values should actually restart the connection.

The reflex fix — deleting theme from the array and silencing the lint warning — trades one bug for a worse one: the notification that fires on connect now shows whatever theme was active the moment the connection opened, forever, no matter how many times the user flips the switch afterward. That's a stale closure, and it's the single most common useEffect bug there is.

React 19.2 (released October 1, 2025) shipped a real fix: the useEffectEvent Hook. It lets an Effect read a value's current state without that value forcing the Effect to re-run — something no combination of dependency-array tricks could do safely before. This article covers what useEffectEvent actually does, the exact rules for using it, and the one way people keep misusing it that makes things worse, not better.

This article is written against React 19.2+ / 19.3.0 (verified against the current npm latest dist-tag for react and cross-checked coverage of the official 19.2 release notes and the React docs' "Separating Events from Effects" guide; react.dev itself was unreachable from this run's network, so claims here lean on the npm registry plus multiple independent write-ups of the same release). useEffectEvent requires React 19.2 or later and assumes React 19-era function components and hooks throughout.

What you'll learn

By the end of this article you'll be able to:

  • Explain the real difference between a value an Effect must depend on and a value an Effect only needs to read the latest copy of
  • Reproduce the stale-closure bug that "just remove it from the deps array" causes, and name why it happens
  • Extract non-reactive logic out of an Effect with useEffectEvent and call it correctly
  • List the hard rules — where it can be called, what it can't be passed to, why it's excluded from the dependency array — and what breaks if you ignore them
  • Recognize when useEffectEvent is the wrong tool, because the value genuinely should resynchronize the Effect

Who this is for

You've written a useEffect with a dependency array, you've seen the react-hooks/exhaustive-deps lint warning, and you've been tempted to either silence it or just add everything it asks for. No class components or Server Components knowledge required.

Table of contents

The problem: one dependency array, two different jobs

Here's the chat room, written the obvious way:

function ChatRoom({ roomId, theme }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on("connected", () => {
      showNotification("Connected!", theme);
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, theme]);

  return <h1>Welcome to {roomId}</h1>;
}
Enter fullscreen mode Exit fullscreen mode

react-hooks/exhaustive-deps is doing its job here: the Effect reads both roomId and theme, so both are listed. The code is "correct" by the lint rule's own standard. It's also wrong in a way the lint rule has no way to catch, because the rule only knows one move — list everything you read — and that move doesn't distinguish between two genuinely different jobs a value can have inside an Effect:

  1. roomId decides what the Effect does. A different room means a different connection. If roomId changes and the Effect doesn't re-run, you're connected to the wrong room. This is a reactive value — the Effect's whole reason for existing is synchronized to it.
  2. theme is only read once the connection is already open, to decide what the notification looks like. It has no bearing on which room you connect to. Listing it as a dependency doesn't make the code more correct — it makes the Effect re-run (disconnect, then reconnect) for a reason that has nothing to do with what the Effect is for.

Toggle dark mode in this version and the user drops their connection. Not because anything about the connection needed to change — because the lint rule, correctly, cannot tell the difference between "this value controls what the Effect synchronizes with" and "this value is read once a thing the Effect already started happens to fire."

The mental model: reactive values vs. Effect Events

The mental model: a useEffect dependency array isn't a list of "everything this function touches" — it's a list of reactive values the Effect's job depends on. If a value changing should legitimately restart what the Effect is doing, it belongs in the array, full stop; that's not a style choice, it's correctness. But some reads inside an Effect aren't about what the Effect is doing at all — they're about what to do when something the Effect already set up happens later. React 19.2 gives that second category a name — an Effect Event — and a Hook to create one: useEffectEvent.

An Effect Event is a function, declared with useEffectEvent(fn), whose body is deliberately non-reactive: every time your component re-renders, React relinks the function to that render's latest props and state, but the function itself is never a dependency and never forces anything to re-run. You call it from inside an Effect (or from inside another Effect Event) when the thing it does should happen using whatever is current right now, not whatever was current when the Effect last ran.

Put plainly: roomId answers "what should this Effect be connected to?" — that's reactive, and it belongs in the array. theme answers "what should happen, using today's value, when the thing I already connected to does something?" — that's an Effect Event, and it has no business in the array at all.

Stage 1 — the naive fix and its stale closure

Before useEffectEvent existed, the common workaround was to simply drop theme from the array and disable the lint rule:

function ChatRoom({ roomId, theme }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on("connected", () => {
      // theme is captured from THIS render, forever, until roomId changes again
      showNotification("Connected!", theme);
    });
    connection.connect();
    return () => connection.disconnect();
    // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally wrong, see below
  }, [roomId]);

  return <h1>Welcome to {roomId}</h1>;
}
Enter fullscreen mode Exit fullscreen mode

This stops the reconnect. It also freezes theme at whatever it was the moment the Effect last ran. JavaScript closures capture variables by reference to the scope they were created in, not by re-reading them later — so the theme inside connection.on("connected", …) is the one value from the render that created this particular closure. Flip dark mode ten times after that; the notification keeps showing the old theme until roomId changes and the whole Effect reruns. That's the stale closure: the code looks like it reads theme, but it's reading a frozen copy from the past.

This is the trap of "just remove it from the deps array and silence the warning": you've fixed the symptom (unwanted reconnects) by reintroducing the exact class of bug the lint rule exists to prevent (stale reads), just somewhere the rule can no longer see it.

Stage 2 — useEffectEvent, correctly

useEffectEvent splits the two jobs apart explicitly instead of hiding one of them:

import { useEffect, useEffectEvent } from "react";

function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    // Always reads the CURRENT theme, no matter when this runs.
    showNotification("Connected!", theme);
  });

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on("connected", () => onConnected());
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // only roomId — and this time that's actually correct, not a lie

  return <h1>Welcome to {roomId}</h1>;
}
Enter fullscreen mode Exit fullscreen mode

Key concept: onConnected is recreated on every render like any other local function, but calling it never reads a frozen snapshot — React always runs it against the component's latest props and state. Its identity is deliberately not stable across renders (unlike a useState setter or a useCallback-memoized function) — that's by design, so nothing can accidentally rely on it staying the same or pass it around like a normal callback. It simply can never legally appear in the dependency array in the first place, so the instability never causes a re-run. The Effect's dependency array now says something true: this Effect only needs to re-run when roomId changes. theme can change a thousand times without touching the connection, and the notification will still report whatever theme is active the instant it fires.

Toggle dark mode now: no disconnect, no reconnect, and the next notification shows the theme you just switched to. Both halves of the original bug — unwanted reconnects and stale reads — are gone, because they were never actually the same problem. They were two different values wearing one dependency array.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

Stage 3 — more than one Effect Event

The pattern scales past a single notification. Say the chat room also has a mute toggle that should silence a "ping" sound without, again, reconnecting:

function ChatRoom({ roomId, theme, isMuted }) {
  const onConnected = useEffectEvent(() => {
    showNotification("Connected!", theme);
  });

  const onMessage = useEffectEvent((message) => {
    if (!isMuted) playSound();
    logMessage(message, theme);
  });

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on("connected", () => onConnected());
    connection.on("message", (msg) => onMessage(msg));
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // roomId is the only reactive value here

  return <h1>Welcome to {roomId}</h1>;
}
Enter fullscreen mode Exit fullscreen mode

Key concept: theme and isMuted can each change independently, as often as the user likes, and neither one is a dependency anywhere. The rule that decides what goes in useEffect's array hasn't changed — list every reactive value the Effect's behavior depends on — it's just that theme and isMuted were never one of those to begin with. They were Effect Event inputs all along; useEffectEvent just gives that distinction a real API instead of a lint-rule workaround.

Edge cases and gotchas

  • You can only call an Effect Event from inside an Effect, or from inside another Effect Event. Calling it during render, or handing it to a child component or another Hook as a prop, is not supported — eslint-plugin-react-hooks (6.1 and later; the current major is 7.x) flags both.
  • It must be declared at the top level of a component or custom Hook, like any other Hook — not inside a loop, condition, or nested function. If you need one per item in a list, pull that item into its own component.
  • It cannot go in a dependency array. You don't need to add it — the lint rule never asks you to — and if you add it anyway, a current eslint-plugin-react-hooks flags it directly (Functions returned from useEffectEvent must not be included in the dependency array) and tells you to remove it, because the whole point is that it isn't reactive.
  • It is not a blanket fix for exhaustive-deps warnings. If a value genuinely should restart the Effect — roomId in every example above — wrapping it in useEffectEvent to make the warning go away reintroduces the exact stale-closure bug Stage 1 showed, just with a newer API wrapped around it. The question to ask every time is "should this value actually restart what the Effect is doing?", not "how do I make the warning stop."
  • It doesn't replace useRef for everything. If you need to read a value from outside any Effect at all — say, inside a setTimeout callback registered by an event handler, not an Effect — useEffectEvent's restriction to Effects means a ref, or a plain callback prop, is still the right tool.
  • Server Components never run Effects, so this is purely a client-component concern; it doesn't change anything about the server/client boundary.

Best practices

  • Reach for useEffectEvent when an Effect's trigger condition should stay narrow (one connection per roomId) but an action it fires needs to use whatever is current at the moment it fires, not whatever was current when the Effect last started.
  • Don't reach for it reflexively every time exhaustive-deps complains. First ask whether the flagged value should actually resynchronize the Effect. If yes, keep it in the array — that's not friction, that's the rule doing its job.
  • Keep Effect Events small and named for what they do (onConnected, onMessage), not for what they avoid (stableTheme). The name should describe the moment it fires, because that's the mental model you're encoding.
  • Pair it with a real connection/subscription pattern, not a one-off side effect. The value of splitting reactive setup from non-reactive reaction shows up most in Effects that stay alive across renders — connections, subscriptions, timers — not in an Effect that runs once and is done.

FAQ

Is useEffectEvent a replacement for useCallback?

No. useCallback memoizes a function's identity across renders so a child doesn't re-render or a dependency array doesn't see a "new" function every time. useEffectEvent doesn't memoize for performance — it changes what the function is allowed to see (always-latest, never-reactive) and where it can be called from (only Effects and other Effect Events). They solve different problems and aren't interchangeable.

Can I call an Effect Event from a click handler?

No — an Effect Event can only be called from inside an Effect or another Effect Event, and the lint rule enforces this. If you need current-value behavior inside an onClick, you already have it: a normal event handler always closes over the latest render's values naturally, because it's recreated every render. The stale-closure problem this Hook solves is specific to long-lived Effects, not short-lived event handlers.

Did this exist before React 19.2?

Yes, behind an unstable name. It shipped for a long stretch as experimental_useEffectEvent, and the stabilized, unprefixed useEffectEvent landed in React 19.2 (October 2025). Code written against the experimental name needs the import updated, but the behavior and rules carried over.

Does useEffectEvent need a special ESLint setup?

You need a current eslint-plugin-react-hooks (6.1+; 7.x as of this writing) for the linter to correctly exclude Effect Event functions from dependency-array warnings instead of flagging them as missing. Without it, you'll fight the linter over something it already knows how to handle in a current version.

What happens if I put the Effect Event function in the dependency array anyway?

A current eslint-plugin-react-hooks flags it and tells you to remove it — it won't silently let it through. Functionally, adding it wouldn't do anything useful even if the linter allowed it: the function reference itself isn't what makes the behavior reactive or not. The reactivity question was never about the Effect Event's identity; it's about the values inside it, which are intentionally not tracked.

Cheat sheet

Situation What to do Why
A value decides what the Effect connects to / sets up Keep it in the dependency array It's reactive — the Effect's job changes when it changes
A value is only read after something the Effect set up fires Read it inside a useEffectEvent Always current, never forces a re-run
Tempted to silence exhaustive-deps by deleting a dependency Ask: should this value restart the Effect? If yes, keep it. If no, wrap the code that reads it in useEffectEvent Deleting it silently reintroduces a stale closure
Need current-value behavior inside an onClick/onChange Do nothing special Event handlers already close over the latest render
Need current-value behavior inside a setTimeout set up outside any Effect Use a ref useEffectEvent only works from inside Effects
import { useEffect, useEffectEvent } from "react";

function ChatRoom({ roomId, theme, isMuted }) {
  // Non-reactive reads live here — never in the deps array, never the trigger.
  const onConnected = useEffectEvent(() => {
    showNotification("Connected!", theme);
  });

  useEffect(() => {
    // Only truly reactive values live here.
    const connection = createConnection(roomId);
    connection.on("connected", () => onConnected());
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);

  return <h1>Welcome to {roomId}</h1>;
}
Enter fullscreen mode Exit fullscreen mode

Key takeaways

  • A useEffect dependency array lists reactive values the Effect's job depends on — not every value the Effect's body happens to read.
  • Deleting a value from the array to stop an unwanted re-run reintroduces a stale closure: the deleted value gets frozen at whatever it was when the Effect last ran.
  • useEffectEvent (stable since React 19.2, October 2025) gives non-reactive reads a real home: the function always sees the latest props/state, but it's never a dependency and never retriggers the Effect.
  • It can only be called from inside an Effect or another Effect Event — never during render, never passed to a child or another Hook.
  • It is not a workaround for exhaustive-deps — if a value should genuinely resynchronize the Effect, it still belongs in the array.

🧠 Test yourself

Think it clicked? Take the 8-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

That chat room from the opening scene now reconnects exactly when it should — when the room changes — and never when someone just prefers a darker screen. The bug was never really about dark mode; it was two different questions hiding inside one dependency array, and useEffectEvent is what finally lets you answer them separately.

If React Derived State taught you that an Effect syncing one piece of state into another is usually a bug, this is the companion lesson for the Effects you do need: not every value they read is there for the same reason, and now there's a real API for telling them apart. And if Re-render vs Remount taught you that key decides which component instance you're looking at, the same instinct applies here — roomId decides which connection you're looking at, and theme never should have.

Have you shipped a stale-closure bug from an Effect's dependency array — did you find it from a bug report, or from reading the code? Tell me below.

📚 Read next


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Top comments (0)