Command Palette

Search for a command to run...

Hectal
PHASE 7Intermediate React 19.2+ ~24 min· topic 9 of 9

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-disable comment 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.

ChatRoom.jsxwhole filejsx
useEffect(() => {
  const connection = createConnection(roomId);
  connection.on("connected", () => showToast("Connected!", theme));
  connection.connect();
  return () => connection.disconnect();
}, [roomId, theme]); // 🔴 a theme change reconnects

02Silencing 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.

ChatRoom.jsxwhole filejsx
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, [roomId]); // 🔴 toast shows an old theme

03Extract 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.

ChatRoom.jsxwhole filejsx
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 roomId
Extract the event-like partdiagram
Rendering diagram…

04Follow the rules

The linter checks how Effect Events are used. These are its real messages.

Terminalwhole filebash
$ 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. 1

    Make the cart reactive again

    In the analytics example, add cartCount to the effect's array and call logVisit(url, cartCount) directly in the effect. Predict the log. (Two extra "visits" to /home when items are added: wrong analytics.)

  2. 2

    Read url inside the event

    Change onVisit to take no argument and read url inside it. It still works, but the effect's code no longer shows *why* it runs. Which version would you rather review?

  3. 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

The problem: a theme change reconnects the chat New tab

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

Press Run to see the page.

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)
The fix: an Effect Event reads the latest theme React 19.2+ New tab

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

Press Run to see the page.

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)
Pass the reactive value as an argument React 19.2+ New tab

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

Press Run to see the page.

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 cart

Break 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).

terminal
$ Console
── what you'll see ──
Uncaught Error: A function wrapped in useEffectEvent can't be called during rendering.
An error occurred in the <ChatRoom> component.
 
Consider adding an error boundary to your tree to customize error handling behavior.
Visit https://react.dev/link/error-boundaries to learn more about error boundaries.

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).

terminal
$ Console
── what you'll see ──
toast: connected! (light theme)
(after toggling to dark and changing rooms, the toast still says light: a stale value)

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 calling ref.current() inside effects. useEffectEvent replaces this pattern with a supported, lint-aware API.

  • ▸

    eslint-plugin-react-hooks (7.x) knows Effect Events: exhaustive-deps doesn't ask for them and reports them if listed, and rules-of-hooks reports 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 wrap callback in an Effect Event so changing the callback never resets the timer, while delay stays reactive.

Remember this

  1. 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 current theme" is not.

  2. 2

    The problem. If the effect reads theme, the linter requires theme in the dependency array, so toggling the theme disconnects and reconnects the chat for no reason. If you leave theme out (and silence the linter), the effect's callback keeps the theme from the render when it last ran: the stale value problem, because the closure (see /javascript closures) captured an old value. Neither is acceptable.

  3. 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 reads theme directly, so theme leaves the dependency array honestly and the linter agrees. Effect Events are imported from react and were added in React 19.2 (they existed earlier as an experimental API).

  4. 4

    Pass reactive values the effect reacted to as arguments: const onVisit = useEffectEvent((visitedUrl) => logVisit(visitedUrl, cartCount)) called as onVisit(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). Reading url inside the event would also work but hides that intent, and with async code the argument keeps the value the effect actually ran for.

  5. 5

    The rules (enforced by eslint-plugin-react-hooks and by React): call useEffectEvent at 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 like onClick (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. 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

01

What problem does useEffectEvent solve? Give an example.

02

What is the stale value problem, and why does silencing the linter cause it?

03

List the rules for using Effect Events.

04

How is an Effect Event different from a function wrapped in useCallback?

Practice

01

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.

02

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-deps to drop a dependency.