Skip to content
Chapter 25Lesson 3

useEffectEvent and the non-reactive seam

React 19.2's useEffectEvent hook reads the freshest props and state inside an effect without adding those values to its dependency array.

In the previous lesson we hit the non-reactive trap: the dependency array demands every reactive value the effect reads, but obeying it made a chat socket reconnect on every keystroke.

The fix turns on one distinction. Some values an effect reads should make it re-synchronize when they change, and the dependency array lists exactly those. Others should not, and useEffectEvent lets the effect read them at their freshest without adding them to the deps. This lesson finishes that bug.

A quick refresher, since the lesson builds on it. A chat component connects to a room over a WebSocket , keyed by roomId: setup connects, cleanup disconnects, and a roomId change re-syncs by disconnecting the old room and connecting the new one. That is correct because roomId is reactive: a new room genuinely means “rebuild the connection.”

Now extend it the way real chat features grow. When a message arrives, the effect should hand it up to the parent tagged with whoever is currently logged in:

'use client';
export const ChatRoom = ({ roomId, onMessage }: ChatRoomProps) => {
const currentUser = useCurrentUser();
useEffect(() => {
const connection = connectToRoom(roomId);
connection.on('message', (message) => {
onMessage(message, currentUser);
});
connection.connect();
return () => connection.disconnect();
}, [roomId, onMessage, currentUser]);
// ...render the message list
};

The dependency array [roomId, onMessage, currentUser] is correct: the setup reads all three, so the lint rule demands exactly these three. onMessage is a prop from the parent, currentUser comes from context. Exhaustive-deps is satisfied, and yet the code is broken.

The contract says those values belong in the array, but listing them re-runs the effect whenever any one changes, and none of those changes is a reason to reconnect. onMessage is a fresh function on every parent render, so its identity changes constantly. currentUser changes when the avatar loads or the display name is edited. Neither event touches the chat connection, yet each one tears the socket down and builds it back up.

The figure below scrubs through four things that happen to a mounted chat component. Notice which reconnects you actually asked for.

mount Component mounted, deps captured for the first time
<ChatRoom /> roomId: "general"
socket connected to general

One connection, exactly as asked. Nothing has re-run yet.

Setup runs once. The socket connects to general. So far so good.
wasted Parent re-rendered → new onMessage identity
<ChatRoom /> roomId: "general" (unchanged)
socket disconnect general reconnecting… connected to general

Same room, brand-new connection. The room never changed — this teardown bought nothing.

The parent typed in some other input and re-rendered. onMessage got a fresh identity, the deps changed, and the socket tore down and rebuilt — to the very same room. One keystroke killed a healthy connection.
wasted currentUser changed (avatar finished loading)
<ChatRoom /> roomId: "general" (unchanged)
socket disconnect general reconnecting… connected to general

A profile field moved, so the socket dropped — still the very same room.

A field on currentUser changed and dropped the socket too. Changing who you are shouldn't disconnect your chat.
wanted roomId changed: "general" → "random"
<ChatRoom /> roomId: "random" (new room)
socket disconnect general reconnecting… connected to random

A genuine room change — the destination really is different. This is the only reconnect that earned its keep.

Only this last reconnect was the one we wanted — a real room change, a real re-sync. The other three were pure waste.

Only one of the four was a real room change. In a real app the other three are not just wasteful, they cause visible bugs: messages dropped mid-reconnect, reconnect storms that hammer the server on every parent render, and a message list that flickers as the socket cycles. The obvious fix, deleting onMessage and currentUser from the array, is the move the last lesson warned against: once the effect stops re-running on those values, its closure goes stale, and the socket keeps calling yesterday’s onMessage with yesterday’s currentUser.

Both options are bad: in the deps, the socket reconnects too often; out of the deps, it calls stale functions. What we need is to read onMessage and currentUser at their latest inside the effect while keeping them out of the deps. That gap, reading fresh without re-syncing, is the non-reactive seam. The next section defines it before we reach for new syntax.

The dependency array lists reactive values

Section titled “The dependency array lists reactive values”

The dependency array is not “every variable the effect touches.” It’s the list of reactive values, and a value is reactive only if a change to it should re-synchronize the effect. A value the effect reads but should not re-sync on is non-reactive, even though the effect reads it. Reading and re-syncing are different relationships, and only the second one earns a slot in the array.

That is the test. Run it on each value in the chat effect:

  • roomId is reactive. A change means disconnect this room and connect that one, so the change is the re-sync. It belongs in the deps.
  • currentUser is non-reactive. The effect needs the latest user when a message arrives, to attribute it correctly, but a new user is no reason to drop and rebuild the socket. Read it fresh; don’t re-sync on it.
  • onMessage is non-reactive. The parent hands down a new function each render, and the connection has no reason to care that the parent re-rendered. Read the latest one when a message comes in; don’t re-sync because its identity moved.

The test is not “does this value change often?” — roomId may change rarely and is still reactive. It is not “is this a function or an object?” The only question is consequence: when this value changes, do I want the effect to tear down and rebuild? Yes puts it in the deps; no makes it non-reactive.

Now practice on values outside the chat example, so you’re applying the rule rather than recognizing it. Sort each value an effect might read into the bucket that fits.

An effect reads each of these values at run time. Sort each by whether its change should re-run the effect. Drag each item into the bucket it belongs to, then press Check.

Reactive Its change should re-run the effect — it goes in the deps.
Non-reactive Read its latest value, but its change must not re-sync.
The url an effect subscribes to for server-sent events
The serverUrl a subscription connects to
The theme an effect applies to a third-party chart
The roomId a socket connects to
The currentUser logged alongside each tracked event
An onComplete prop called when an animation finishes
The current filters read inside each polling tick
An analytics onEvent callback passed down from props

The need is now precise: read currentUser and onMessage at their latest from inside the effect, while telling React and the linter they are not reactive and must stay out of the deps. React 19.2 gives that capability a name.

useEffectEvent: read fresh state without re-running

Section titled “useEffectEvent: read fresh state without re-running”

useEffectEvent, imported from 'react' since React 19.2, carves a piece of logic out of an effect into a callback that always reads the freshest props and state but is invisible to the dependency array. You declare it next to the effect, the effect calls it, and the values it reads stop counting as dependencies.

The two tabs show the same component before and after. The first is the reconnect storm you just watched; the second routes the non-reactive reads through an Effect Event .

'use client';
export const ChatRoom = ({ roomId, onMessage }: ChatRoomProps) => {
const currentUser = useCurrentUser();
useEffect(() => {
const connection = connectToRoom(roomId);
connection.on('message', (message) => {
onMessage(message, currentUser);
});
connection.connect();
return () => connection.disconnect();
}, [roomId, onMessage, currentUser]);
// ...
};

Every value in the array is treated as reactive, and that’s the bug. onMessage and currentUser belong in the deps by the contract, but their changes have nothing to do with the connection, so every parent re-render and every profile tweak runs that disconnect/reconnect cycle.

The connection lifecycle and the message-handling logic used to share one dependency array; now reactivity separates them. Walk through the after version one piece at a time.

const onReceiveMessage = useEffectEvent((message: Message) => {
onMessage(message, currentUser);
});
useEffect(() => {
const connection = connectToRoom(roomId);
connection.on('message', onReceiveMessage);
connection.connect();
return () => connection.disconnect();
}, [roomId]);

useEffectEvent returns a function. Every time it is called, it reads the current onMessage and currentUser, never a snapshot from an earlier render. This is the “read latest” half of the seam.

const onReceiveMessage = useEffectEvent((message: Message) => {
onMessage(message, currentUser);
});
useEffect(() => {
const connection = connectToRoom(roomId);
connection.on('message', onReceiveMessage);
connection.connect();
return () => connection.disconnect();
}, [roomId]);

onMessage and currentUser are deliberately absent from the deps, and the lint is fine with that: the rule knows about Effect Events and excludes them. Only the reactive roomId remains, so it alone re-syncs the socket.

const onReceiveMessage = useEffectEvent((message: Message) => {
onMessage(message, currentUser);
});
useEffect(() => {
const connection = connectToRoom(roomId);
connection.on('message', onReceiveMessage);
connection.connect();
return () => connection.disconnect();
}, [roomId]);

The effect wires the Effect Event up as the message handler. When a message arrives, possibly long after setup ran, the Event fires and reads whatever onMessage and currentUser are current at that moment. Connection lifecycle and message handling are now separate.

1 / 1

The contract in one line:

The chat socket was one instance of a recurring pattern: the same reactive/non-reactive split, dressed differently each time. Here are the three canonical cases.

Event-shaped callbacks from a third-party widget. You hand a charting or mapping library a DOM node and a handler like onPointClick or onMove, then tear the widget down when its config changes. The config is reactive: a new config means re-instantiating the widget. The handler is not; it is just a function the parent re-creates every render. List it in the deps and the widget is destroyed and rebuilt on every parent render. Wrap it in an Effect Event and the widget rebuilds only when the config actually changes.

const onPointClick = useEffectEvent((point: DataPoint) => {
onSelect(point, currentRange);
});
useEffect(() => {
const chart = createChart(node, { onPointClick });
return () => chart.destroy();
}, [chartConfig]);

Logging fired from inside an effect. An effect runs on a reactive change, such as a route becoming visible or a panel opening, and logs the event with some context. The trigger is reactive; the logged context is not. A page-view log is the classic shape:

const logVisit = useEffectEvent(() => {
track('page_view', { url, referrer, userId: currentUser.id });
});
useEffect(() => {
logVisit();
}, [url]);

url stays the only dependency, so each page logs one visit, while logVisit still reads currentUser and referrer at their freshest. Had currentUser been a dependency, a profile change would have logged a second, bogus visit to the same URL.

Reading mutable state inside an interval. This case causes the most trouble, so it gets the practice. You set up a poll once on mount with setInterval, and each tick needs the latest value of some state, such as a set of filters, a pageSize, or a step. The naive instinct is to list that state in the deps, but then every change clears the interval and starts a new one, resetting the timer; a user adjusting a filter restarts the polling clock on every keystroke.

const onTick = useEffectEvent(() => {
refetch(filters);
});
useEffect(() => {
const id = setInterval(onTick, 5000);
return () => clearInterval(id);
}, []);

With empty deps the interval is created exactly once, yet every tick reads the current filters through the Effect Event: the timer never resets, and the data always reflects the latest filters. Now try it yourself. The exercise below hands you a polling component written the naive way, where changing a value visibly restarts the clock. Move the latest-value read into an Effect Event so the timer survives a change.

This counter ticks up by step every second, but step sits in the interval effect's deps — so every time you press the button, the interval is torn down and recreated and the 1-second clock restarts. Move the latest-step read into a useEffectEvent (imported from 'react') and change the deps to [], so the interval is created exactly once yet every tick still adds the current step. Press the button a few times: the interval should set up just once.

Preview
    Reference solution
    import { useEffect, useEffectEvent, useState } from 'react';
    export function App() {
    const [count, setCount] = useState(0);
    const [step, setStep] = useState(1);
    const onTick = useEffectEvent(() => {
    setCount((c) => c + step);
    });
    useEffect(() => {
    const id = setInterval(onTick, 1000);
    return () => clearInterval(id);
    }, []);
    // ...render the count, the step, and the button
    }

    The tick reads the latest step through onTick, so the interval no longer depends on it. The deps array is empty, the timer is created exactly once, and every tick still adds the current step.

    1. Call it only from inside an Effect or another Effect Event, never from render and never from a regular event handler. This is the rule people break most often, and it has two halves. Calling it during render would read the latest mutable state while rendering, the exact impurity The purity contract forbids; the React Compiler rejects it. A regular event handler doesn’t need it: handlers already run on a fresh render and close over the latest props and state, so the seam is redundant there and the lint flags it.

    2. Never pass it as a prop or return it out of a hook. Its identity is intentionally unstable, so anything that keys off that identity, such as a memoized child or a dependency array two levels up, breaks loudly. If a child needs the behavior, the child declares its own Effect Event. You may create an Effect Event inside a custom hook and call it from that hook’s own effect; what you must never do is hand the Effect Event back to the hook’s callers.

    3. It’s excluded from dependency arrays by design. The exhaustive-deps rule ignores Effect Events when checking deps and separately flags any call made outside an allowed context. The tool that demands every reactive value also recognizes that this one isn’t reactive.

    4. Keep the body event-shaped. Read the latest values and perform an action: fire a callback, log an event, kick off a request. Don’t declare hooks inside it, and don’t compute values meant to drive a re-render. It’s an event handler that happens to live next to an effect, not a hiding place for reactive logic.

    The first rule is worth seeing as code, because the wrong version looks so reasonable.

    const onVisit = useEffectEvent(() => {
    track('open', { url, userId: currentUser.id });
    });
    const handleClick = () => {
    onVisit();
    };

    Calling an Effect Event from an ordinary handler is a misuse the lint will flag. handleClick already runs on a fresh render, so it can read url and currentUser directly. Routing through the seam only hides the logic.

    One detail still needs explaining: why is the identity unstable on purpose?

    The deps array lets you omit some values, the set function from useState, the dispatch from useReducer, a ref’s current, because React gives them the same identity for the component’s whole life. They’re omittable because they’re stable. An Effect Event is omittable for the opposite reason: its identity changes on every render, and it’s excluded from deps not because it never changes but because it’s declared non-reactive.

    That instability is a feature. If you wrongly wire an Effect Event’s identity into a real dependency, by passing it as a prop into a memoized child or listing it where it doesn’t belong, the effect re-runs on every render and the mistake surfaces at once. A stable function would let the same mistake pass unnoticed; React keeps the identity changing so misuse fails visibly.

    What this replaces: the ref mirror and the useCallback wrap

    Section titled “What this replaces: the ref mirror and the useCallback wrap”

    useEffectEvent landed in React 19.2. Before it existed, engineers hit the non-reactive trap constantly and reached for one of two workarounds. You’ll meet both in existing and AI-generated code, so it’s worth knowing what they do and why this hook replaces them.

    The first is the ref mirror: stash the latest callback in a ref, keep ref.current updated in its own effect, then read ref.current() from inside the effect that needs it.

    const onMessageRef = useRef(onMessage);
    useEffect(() => {
    onMessageRef.current = onMessage;
    });
    useEffect(() => {
    const connection = connectToRoom(roomId);
    connection.on('message', (message) => onMessageRef.current(message));
    connection.connect();
    return () => connection.disconnect();
    }, [roomId]);

    It works: the ref always holds the newest callback, and roomId stays the only dependency. But it has two problems. The ref updates after commit, so a read that fires before that update is one render behind. And nothing enforces it: the lint can’t tell this ref is meant to mirror onMessage, so a subtle mistake goes unflagged. useEffectEvent is this exact pattern with correct timing and lint support built in.

    The second is the useCallback wrap: stabilize the callback’s identity so it can sit in the deps array without firing the effect constantly.

    const handleMessage = useCallback(
    (message: Message) => onMessage(message, currentUser),
    [onMessage, currentUser],
    );
    useEffect(() => {
    const connection = connectToRoom(roomId);
    connection.on('message', handleMessage);
    connection.connect();
    return () => connection.disconnect();
    }, [roomId, handleMessage]);

    This is the tempting choice, but it doesn’t work. useCallback returns a stable function whose body still closes over the render that created it, so its own dependency array has to be correct, and if it isn’t, you’re back to a stale closure. And when its deps legitimately change, say currentUser updates, handleMessage gets a new identity, which re-runs the effect and reconnects the socket. That is the exact bug you were trying to fix.

    The same chat fix across three eras, side by side:

    const onMessageRef = useRef(onMessage);
    useEffect(() => {
    onMessageRef.current = onMessage;
    });
    useEffect(() => {
    const connection = connectToRoom(roomId);
    connection.on('message', (m) => onMessageRef.current(m));
    connection.connect();
    return () => connection.disconnect();
    }, [roomId]);
    Works, but onMessageRef.current updates after commit, one beat behind, and the lint can’t check that the ref mirrors onMessage.

    Now choose between the tool that looks right and the one that is.

    A parent passes a fresh onMessage function to your ChatRoom on every render, and ChatRoom reads currentUser from context. The socket must reconnect when roomId changes — and only then. Which approach keeps the connection stable while still calling the newest onMessage and currentUser?

    Memoize onMessage with useCallback so its identity is stable, then list it in the effect’s deps.
    Move the call to onMessage(message, currentUser) into a useEffectEvent, call it from the socket’s message handler, and keep [roomId] as the only dependency.
    Drop onMessage and currentUser from the deps array and disable the exhaustive-deps lint rule for that line.
    Store roomId in an Effect Event so the effect never has to list it as a dependency.

    The cleanest fix invites its own mistake. Once you’ve seen how useEffectEvent silences a noisy dependency, it’s tempting to wrap everything in it and stop thinking about deps. Resist that: it turns reactive logic into non-reactive logic and quietly breaks synchronization. Bury roomId in an Effect Event and the socket that should reconnect on a room change no longer does, a loud bug traded for a quiet one.

    So make it the last move, in this order:

    1. First ask whether you need the effect at all. Most “I need the latest state in an effect” instincts dissolve once you notice the effect shouldn’t exist: the value is derived and belongs in render, the logic belongs in an event handler, or the data belongs in a Server Component. A later lesson, “You probably don’t need an effect,” is the full audit. This hook is only for the cases where an effect is genuinely warranted.
    2. Then, value by value, ask which reads are reactive. Only the non-reactive reads move into an Effect Event; the reactive ones stay in the deps. useEffectEvent exempts specific reads, not the whole dependency contract.

    One last check, on a scenario you haven’t seen. Below, a typing-indicator effect reads four values. Run the test once more to decide which belong in the deps and which belong in an Effect Event.

    A presence feature opens a connection with openPresence(channelId, region), and on each keypress in the composer it broadcasts who’s typing by calling the parent’s onTyping(currentUser). The effect reads four values:

    useEffect(() => {
    const channel = openPresence(channelId, region);
    channel.onKeypress(() => onTyping(currentUser));
    return () => channel.close();
    }, [/* ? */]);

    Run the bright line on each: which values are reactive — a change should close the open channel and reopen a fresh one? Select all that apply.

    The data-center region passed into openPresence alongside the channel
    The user object handed to onTyping so the broadcast says who’s typing
    The channelId argument that picks which presence channel openPresence joins
    The parent’s keypress callback the effect invokes on every keystroke

    The React docs use the same reactive-versus-non-reactive framing. “Separating Events from Effects” is the long-form explanation; the useEffectEvent reference page lists the rules in full.

    To watch the whole arc worked live in an editor, from the dependency-array pain through the stale-closure trap to the fix, this walkthrough covers the same ground from another angle.