Topic 7.9
Separating Events from Effects: useEffectEvent
In one line
Some logic inside an effect should see the latest props and state without making the effect re-run when they change; useEffectEvent (React 19.2) wraps that logic in an Effect Event, a non-reactive function you call only from inside effects.
Think of it like this
A delivery driver with a route to the customer's address. If the customer changes the address, the driver must turn around: that's reactive. If the customer changes the doorbell note ("ring twice"), the driver keeps driving and simply reads the latest note on arrival. The note matters, but it shouldn't restart the trip. useEffectEvent is the doorbell note: read fresh when needed, never a reason to restart.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Reactive
- Re-runs when the values it reads change. Effects are reactive.
- Non-reactive
- Reads the current values when it runs, but changes don't make it run. Event handlers are non-reactive.
- Effect Event
- A non-reactive function made with
useEffectEvent, called from inside effects, that always sees the latest props and state. - useEffectEvent
- The React 19.2 hook that creates an Effect Event.
- Stale value problem
- A function keeps using an old value because it was created during an earlier render.
- Closure
- A function that remembers the variables from the render where it was created.
- Lint suppression
- An
eslint-disablecomment that hides a warning instead of fixing the cause.
Step by step
01An effect with a non-reactive need
The connection should follow roomId. The toast only needs theme at the moment it shows. But because the effect reads theme, it must depend on it.
useEffect(() => {
const connection = createConnection(roomId);
connection.on("connected", () => showToast("Connected!", theme));
connection.connect();
return () => connection.disconnect();
}, [roomId, theme]); // 🔴 a theme change reconnects02Silencing the linter gives stale values
Removing theme from the array stops the reconnects, but the toast now uses the theme from the last time the effect ran. Never do this.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [roomId]); // 🔴 toast shows an old theme03Extract the event-like part
Move the non-reactive logic into an Effect Event. Inside it, theme is always current. The effect calls it and no longer reads theme.
import { useEffect, useEffectEvent } from "react";
const onConnected = useEffectEvent(() => {
showToast("Connected!", theme);
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on("connected", () => onConnected());
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ honest: the effect reads only roomId04Follow the rules
The linter checks how Effect Events are used. These are its real messages.
$ npx eslint src/Room.jsx
src/Room.jsx
37:15 warning Functions returned from `useEffectEvent` must not be included in the dependency array. Remove `onConnected` from the list react-hooks/exhaustive-deps
38:33 error `onConnected` is a function created with React Hook "useEffectEvent", and can only be called from Effects and Effect Events in the same component react-hooks/rules-of-hooks
✖ 2 problems (1 error, 1 warning)Try it yourself
- 1
Make the cart reactive again
In the analytics example, add
cartCountto the effect's array and calllogVisit(url, cartCount)directly in the effect. Predict the log. (Two extra "visits" to /home when items are added: wrong analytics.) - 2
Read url inside the event
Change
onVisitto take no argument and readurlinside it. It still works, but the effect's code no longer shows *why* it runs. Which version would you rather review? - 3
Toggle the theme by hand
In the fix example, toggle the theme, then switch rooms twice. Each toast uses whatever theme is current at that moment.
Code & diagrams
Correct but wasteful: changing a colour drops and re-opens the network connection. In a real chat, users would see "reconnecting…" every time they toggled dark mode.
Preview
Console
Nothing logged yet.Expected console output
connect to #general
toast: connected! (light theme)
--- toggle theme (same room!)
disconnect from #general
connect to #general
toast: connected! (dark theme)Toggling the theme no longer touches the connection. When the room changes, the effect re-syncs, and the toast uses the **current** theme (dark), not the one from the first render.
Preview
Console
Nothing logged yet.Expected console output
connect to #general
toast: connected to #general! (light theme)
--- toggle theme
--- switch room
disconnect from #general
connect to #travel
toast: connected to #travel! (dark theme)Adding to the cart logs nothing: a cart change isn't a visit. Navigating logs one visit, and it reports the latest cart count (2).
Preview
Console
Nothing logged yet.Expected console output
analytics: visit /home with 0 items in cart
--- add two items
--- navigate
analytics: visit /checkout with 2 items in cartBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Call an Effect Event during render
In the fix example, add {onConnected()} inside the returned JSX (or call it anywhere in the component body).
Break #2
Silence the linter instead
In the problem example, remove theme from the array with // eslint-disable-next-line react-hooks/exhaustive-deps, then toggle the theme and switch the room (add a room button).
Myth vs fact
Myth
useEffectEvent is a way to remove any dependency you don't like.
Fact
Only for logic that is genuinely non-reactive (event-like). If the effect should re-sync when a value changes, the value must stay a dependency.
Myth
An Effect Event is like useCallback.
Fact
useCallback returns a memoised function that is reactive to its dependencies and can be passed anywhere. An Effect Event isn't reactive, always reads the latest values, and may only be called from effects in the same component.
Myth
You can use the Effect Event as an onClick handler.
Fact
Event handlers are already non-reactive; use a normal function. Calling an Effect Event from a handler or render breaks the rules (lint error, and a runtime error during render).
Myth
useEffectEvent has been stable since React 18.
Fact
It was experimental (experimental_useEffectEvent) for a long time and became a stable export in React 19.2.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Implementation: React keeps a ref to the latest callback and updates it during the commit, before layout and passive effects run, so any effect sees the version from its own commit. The function React returns throws if invoked during render.
- ▸
Before 19.2 the usual workaround was a "latest ref":
const ref = useRef(fn); useLayoutEffect(() => { ref.current = fn; });then callingref.current()inside effects.useEffectEventreplaces this pattern with a supported, lint-aware API. - ▸
eslint-plugin-react-hooks (7.x) knows Effect Events:
exhaustive-depsdoesn't ask for them and reports them if listed, andrules-of-hooksreports calls outside effects and passing them around ("It cannot be assigned to a variable or passed down."). - ▸
Effect Events pair naturally with custom hooks (Phase 10): a
useInterval(callback, delay)hook can wrapcallbackin an Effect Event so changing the callback never resets the timer, whiledelaystays reactive.
Remember this
- 1
Recall the two kinds of code (Topic 7.1). Event handlers run in response to a specific interaction and are non-reactive: they read the current props and state when they run, but changing those values doesn't run them. Effects are reactive: every reactive value they read is a dependency, and a change re-runs them (Topic 7.5). Sometimes one effect mixes both: "connect to
roomId" is reactive, but "when connected, show a toast in the currenttheme" is not. - 2
The problem. If the effect reads
theme, the linter requiresthemein the dependency array, so toggling the theme disconnects and reconnects the chat for no reason. If you leavethemeout (and silence the linter), the effect's callback keeps thethemefrom the render when it last ran: the stale value problem, because the closure (see /javascript closures) captured an old value. Neither is acceptable. - 3
The fix.
const onConnected = useEffectEvent(() => { showToast("Connected!", theme); });. This creates an Effect Event: a function that always sees the latest props and state when it's called, but isn't reactive. Call it from inside the effect; the effect no longer readsthemedirectly, sothemeleaves the dependency array honestly and the linter agrees. Effect Events are imported fromreactand were added in React 19.2 (they existed earlier as an experimental API). - 4
Pass reactive values the effect reacted to as arguments:
const onVisit = useEffectEvent((visitedUrl) => logVisit(visitedUrl, cartCount))called asonVisit(url)in an effect with[url]. That makes it explicit which value caused the run (the URL) and which is merely read (the cart count). Readingurlinside the event would also work but hides that intent, and with async code the argument keeps the value the effect actually ran for. - 5
The rules (enforced by
eslint-plugin-react-hooksand by React): calluseEffectEventat the top level of your component, like any hook. Only call the returned function from inside effects (or from other Effect Events) in the same component, never during render (React throws) and never from event handlers likeonClick(just use a normal function there). Don't pass it to other components or hooks. Don't put it in a dependency array (the linter reports it). Declare it right next to the effect that uses it. - 6
Use it sparingly. It is not a way to silence the dependency linter: only extract logic that is truly event-like ("what happens when the connection opens", "what gets logged on a visit"). If the effect should re-sync when a value changes, keep that value reactive.
Explain it without notes
What problem does useEffectEvent solve? Give an example.
What is the stale value problem, and why does silencing the linter cause it?
List the rules for using Effect Events.
How is an Effect Event different from a function wrapped in useCallback?
Practice
An effect starts an interval that adds step to count every second, with deps [step], so changing step restarts the timer. Rewrite it so changing step doesn't restart the interval but the next tick uses the new step.
A useEffect with [productId] logs "viewed", productId, currency. Changing currency shouldn't log a new view. Write it.
Trade-offs
- ↔
Effect Events remove unnecessary re-syncs and honest-dependency headaches, but overusing them hides real dependencies and reintroduces bugs where an effect should have re-synced.
- ↔
On React versions before 19.2 you need the latest-ref workaround or an upgrade.
Done when you can
I can tell reactive logic from non-reactive logic inside an effect.
I can explain the stale value problem.
I can extract non-reactive logic with
useEffectEvent.I know and follow the rules for calling Effect Events.
I never silence
exhaustive-depsto drop a dependency.