Skip to content
Chapter 25Lesson 3

useEffectEvent and the non-reactive seam

React 19.2's useEffectEvent hook, the tool for reading the freshest props and state inside an effect without dragging those values into its dependency array.

In the previous lesson we left one problem open on purpose. The dependency array, we said, is an exact contract: every reactive value the effect reads belongs in it, and the lint rule that enforces this is an ally, not an obstacle. Then we hit a case where obeying the contract produced the wrong behavior, a chat socket that reconnected on every keystroke, and named it the non-reactive trap. The fix was one lesson away, and this is that lesson.

It rests on one hook and one distinction: some values an effect reads should make it re-synchronize when they change, and some should not, and the dependency array is only ever a list of the first kind. useEffectEvent is the tool that lets the effect read the second kind, always at their freshest, without pulling them into the deps. We’ll get there by finishing the bug we started.

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

Now extend it the way real chat features grow. Every time a message arrives, the effect should hand it up to the parent and tag it 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
};

Look at that dependency array: [roomId, onMessage, currentUser]. By the contract from last lesson, it’s correct: the lint rule would demand exactly these three, because the setup reads all three. onMessage is a prop the parent hands down, and currentUser comes from context. The exhaustive-deps rule is satisfied, and yet this code is broken.

The conflict is this. The contract says those values belong in the array, but putting them there means the effect re-runs whenever any of them changes, and a change to them is not a reason to reconnect the socket. onMessage is a fresh function the parent creates on every one of its renders, so its identity changes constantly. currentUser changes when the profile loads an avatar or the display name is edited. Neither of those events has anything to do with the chat connection. But because the values sit in the deps, every one of those changes tears the socket down and builds it back up.

The figure below steps through four things that happen to a mounted chat component. Scrub through it and 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.

Three of those four reconnects were noise. In a real app they aren’t just wasteful, they cause visible bugs: messages dropped mid-reconnect, reconnect storms that hammer the server every time the parent re-renders, and a message list that flickers as the socket cycles. The obvious fix is to delete onMessage and currentUser from the array to stop the churn, but that’s exactly the move the last lesson warned against. Do it and the lint objects, correctly, because the moment the effect stops re-running on those values its closure goes stale: the socket keeps calling yesterday’s onMessage with yesterday’s currentUser.

So both options are bad. Leaving the values in the deps reconnects too often; taking them out leaves the socket calling stale functions. What we actually 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 precisely before we reach for any new syntax.

What the dependency array is actually a list of

Section titled “What the dependency array is actually a list of”

It helps to reframe what the array is for. You’ve probably been picturing the dependency array as “every variable the effect touches,” but that’s not quite it. The dependency array is the list of reactive values, and a value is reactive only if a change to it should cause the effect to re-synchronize. A value the effect reads but whose change should not trigger a re-sync is non-reactive, even though the effect reads it. Reading and re-syncing are two different relationships, and only the second one earns a slot in the array.

That distinction is the test. Run it on each value in the chat effect, one at a time, and the right answer falls out every time.

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

Notice what the test is not asking. It isn’t “does this value change often?”, since roomId might change rarely and is still reactive. It isn’t “is this a function or an object?” The only question is about consequence: when this value changes, do I want the effect to tear down and rebuild? Yes means it goes in the deps. No means it’s non-reactive.

Before we name the tool, practice the distinction on values outside the chat example, so you’re applying the rule rather than recognizing it from the prose. 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 lint that these are not reactive and should stay out of the deps. React 19.2 gives that capability a name.

useEffectEvent: a callback that reads latest and stays out of deps

Section titled “useEffectEvent: a callback that reads latest and stays out of deps”

useEffectEvent, imported straight from 'react' since React 19.2, lets you carve 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 no longer count as dependencies. That is the entire idea. Let’s apply it to the chat room and see what shrinks.

The two tabs below 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. The two panes are identical except for the seam; here all three values sit in the deps. onMessage and currentUser belong there 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.

That is the fix in full. The connection lifecycle and the message-handling logic used to be tangled together in one dependency array; now reactivity separates them cleanly. Walk through the after version one piece at a time so each move is explicit.

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 that function is called, it reads the current onMessage and currentUser, never a snapshot from some 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 understands Effect Events and excludes them on purpose. Only the genuinely reactive roomId remains, so it is the one value that 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 finally separated.

1 / 1

Here is the contract in one line:

That completes the happy path, and it is short because the idea is small: a callback that reads latest and stays out of deps. The rest of the lesson covers where this shape shows up and the guardrails that keep it from going wrong, but you already have the core.

The three places you’ll actually reach for it

Section titled “The three places you’ll actually reach for it”

The chat socket is one instance of a pattern that recurs. Once you’ve seen the shape a few times you’ll spot it without the chat scaffolding. Here are the three canonical cases, each the same reactive/non-reactive split in different clothing.

Event-shaped callbacks from a third-party widget. You hand a charting or mapping library a DOM node and a handler such as onPointClick or onMove, and you tear it down when its config changes. The config is reactive: a new config means re-instantiate the widget. But the handler is just a function the parent re-creates each render. Put it in the effect’s deps and the widget gets destroyed and rebuilt every time the parent re-renders, which is constantly. Wrap the handler in an Effect Event and the widget is rebuilt only when the config genuinely 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 inside it you log 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 lone reactive dependency, so a visit is logged once per page. But logVisit still reads currentUser and referrer at their freshest. If currentUser had been a dependency, a profile change would have logged a second, bogus page view to the same URL. The Effect Event keeps what triggers the log separate from what the log records.

Reading mutable state inside an interval. This is the case that 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 effect’s deps. But then every change to it clears the interval and creates a new one, which resets the timer, so a user adjusting a filter would restart 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.

    With the happy path in hand, here are the guardrails. There are four, and the first is the one people break most often.

    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 hard rule, and it has two halves. Calling it during render would read the latest mutable state while rendering, which is exactly the impurity the render model forbids: render must stay a pure function of its snapshot, and the React Compiler will reject anything else. A regular event handler, meanwhile, doesn’t need it. Handlers already run on a fresh render and close over the latest props and state by default, so reaching for the seam there is redundant, and the lint flags it as a misuse.

    2. Never pass it as a prop or return it out of a hook. Its identity is intentionally unstable, so anything downstream 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. There is one nuance for later: you may create an Effect Event inside a custom hook and call it within that hook’s own effect. What you must never do is hand the Effect Event itself back to the hook’s callers.

    3. It’s excluded from dependency arrays by design, and the lint knows that. You’ve already seen this in action. 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, or 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. The two variants below show the canonical mistake and its fix.

    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. There’s nothing for the seam to do here, and routing through it only hides the logic.

    That leaves one genuinely strange detail to explain: why is the Effect Event’s identity unstable on purpose? Once this clicks, the rules above feel inevitable rather than arbitrary.

    Think back to the values the deps array lets you omit: the set function from useState, the dispatch from useReducer, a ref’s current. You can leave those out of deps because React guarantees they keep 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, deliberately, and it’s excluded from deps not because it never changes but because it’s explicitly declared non-reactive.

    That instability is a feature. Suppose 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. Because the identity changes every render, the effect then re-runs on every single render, so the mistake shows up at once. A stable function would have let the same mistake pass unnoticed. React keeps the identity changing so that misusing the seam fails visibly instead of quietly.

    What this replaces: the ref mirror and the useCallback wrap

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

    The hook is new: it landed in React 19.2, with no experimental_ prefix to worry about. Before it existed, engineers still hit the non-reactive trap constantly and reached for one of two workarounds. You’ll meet both in existing codebases and in AI-generated code, so it’s worth knowing what they were 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 real problems. The ref updates after commit, in an effect, so a read that fires before that update can be one render behind. And nothing enforces it: the lint has no idea this ref is meant to mirror onMessage, so when you get the pattern subtly wrong, nothing tells you. useEffectEvent is precisely this pattern, with correct timing and first-class lint support built in.

    The second workaround is the useCallback wrap: stabilize the callback’s identity so it can sit in the deps array without firing 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 looks like it solves the problem, and it’s the tempting choice, but it doesn’t. useCallback hands back a stable function whose body still closes over the render it was created in, so its own dependency array has to be correct, and if it isn’t, you’re right back to a stale closure. Worse, when its deps do legitimately change, for example when currentUser updates, handleMessage gets a new identity, which re-runs the effect, which reconnects the socket. That is the exact bug you were trying to fix.

    Here is the contrast:

    Here are all three side by side, the same chat fix across three eras, so the difference is easy to see at a glance.

    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.

    Settle the contrast with a quick check. The point is to choose between the tool that looks right and the one that actually 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.

    One closing discipline, because this hook has a failure mode that’s the mirror image of the bug we fixed. Once you’ve seen how cleanly useEffectEvent silences a noisy dependency, you may be tempted to wrap everything in it so you never have to think about deps again. Resist that. It would convert genuinely reactive logic into non-reactive logic and quietly break synchronization: bury roomId in an Effect Event and the socket that should reconnect on a room change no longer does. You’d have traded a loud bug for a quiet one.

    So make it the last move, not the first. The reasoning runs in this order:

    1. First ask whether you need the effect at all. Most instincts of the form “I need to read the latest state in an effect” 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 residual 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, where they belong. useEffectEvent is a scalpel for specific non-reactive reads, not a blanket exemption from the dependency contract.

    Hold onto the one sentence this whole lesson compresses to:

    One last check, on a scenario you haven’t seen. Below is a typing-indicator effect that reads four values. Decide which belong in the deps and which belong in an Effect Event, running the test one more time.

    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 treat reactive versus non-reactive in exactly the framing this lesson used. The “Separating Events from Effects” page is the long-form source of the distinction, and the reference page is where the rules live in full.

    If you’d like to see 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 a different angle.