useEffectEvent

useEffectEvent

Define a stable function you can pass to effects or subscriptions whose body always sees the latest props and state — without putting those values in the effect dependency array and re-subscribing every render.

useEffectEvent(fn) (React 19+) returns a function with a fixed identity for the lifetime of the component. When something calls it (an effect, interval, or child), React runs the latest version of fn you wrote — similar to the “ref + stable wrapper” pattern in useRef, but with explicit semantics and lint rules.


What problem it solves

Long-lived setups — setInterval, addEventListener, WebSocket onmessage, analytics beacons — create a tension:

ApproachSubscriptionFresh props/state
Put handler in effect depsRe-subscribes every changeFresh
Subscribe once with [] depsStableStale closure
Ref updated each render + stable wrapperStableFresh (manual)
useEffectEventStableFresh (built-in)

You want one listener attached, but when it fires it should read today’s count, theme, or onSave callback — not the values from the render when the effect first ran.


Synchronization deps vs “effect events”

Your phrasing is the right mental model:

Use a reactive value inside useEffect, but that value has nothing to do with synchronizing the component with the outside system — so it should not be in the dependency array.

useEffect’s dependency array answers: “When should I tear down and re-run this sync?” (new WebSocket when roomId changes, new fetch when userId changes, remove old listener and attach a new one.)

useEffectEvent answers: “When this sync fires, run logic that may read latest props/state/callbacks, without that read counting as a reason to re-sync.”

Kind of valueRole in the effectBelongs in deps?Typical tool
Sync input — changing it means the external attachment is wrongDefines which connection existsYesuseEffect deps only
Reactive read — only affects what you do when the connection firesObserved inside the handlerNo (use effect event)useEffectEvent
User gesture — one-off actionNot an ongoing syncEvent handler (onClick)
External store — subscribe + snapshotStore drives re-rendersuseSyncExternalStore

Rule of thumb: If changing the value should disconnect and reconnect (or abort and restart) the effect’s setup → dependency. If changing the value should only change behavior on the next callback while setup stays the same → useEffectEvent.


Contrasting use cases (same hooks, different jobs)

1. Fetch when userId changes — useEffect deps only (not useEffectEvent)

userId is the sync contract: a fetch for user 1 must be cancelled when the user becomes 2.

useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then((r) => r.json())
    .then(setUser);
  return () => controller.abort();
}, [userId]); // ✅ userId belongs here

Putting userId inside useEffectEvent and leaving [] deps would hide a real sync boundary — stale requests, wrong user on screen. Do not use useEffectEvent to avoid deps that define which external resource you’re tied to.


2. Interval analytics — useEffectEvent (deps stay [])

You attach one setInterval on mount. Each tick should log the current theme and count, but changing theme should not clear and recreate the interval — that’s not “re-syncing,” it’s just reading fresher data when the timer fires.

// ❌ theme in deps — interval resets every theme change (unnecessary re-sync)
useEffect(() => {
  const id = setInterval(() => {
    analytics.track("heartbeat", { theme, count });
  }, 5000);
  return () => clearInterval(id);
}, [theme, count]);

// ✅ stable interval; reactive reads via effect event
const onHeartbeat = useEffectEvent(() => {
  analytics.track("heartbeat", { theme, count });
});

useEffect(() => {
  const id = setInterval(() => onHeartbeat(), 5000);
  return () => clearInterval(id);
}, []);

theme and count are reactive but not sync inputs for the interval resource.


3. Chat room — split: roomId in deps, onMessage as effect event

function ChatRoom({ roomId, onMessage }) {
  const onMessageEvent = useEffectEvent(onMessage);

  useEffect(() => {
    const socket = connect(roomId);           // sync: which room
    socket.on("message", onMessageEvent);    // observe: latest handler
    return () => socket.disconnect();
  }, [roomId]); // only what redefines the connection
}
ValueSync or observe?Why
roomIdSyncDifferent room → must disconnect old socket
onMessageObserveParent re-render may pass new callback; socket to same room can stay open
// ❌ onMessage in deps — reconnects WebSocket on every parent render
useEffect(() => {
  const socket = connect(roomId);
  socket.on("message", onMessage);
  return () => socket.disconnect();
}, [roomId, onMessage]);

4. Visibility listener — useEffect + functional setState vs useEffectEvent

When you only need the latest state to compute the next state, functional updaters avoid extra deps:

// ✅ no useEffectEvent needed — setState accepts updater
useEffect(() => {
  const handleChange = () => {
    if (document.visibilityState !== "visible") {
      setTabAwayCount((c) => c + 1);
    }
  };
  document.addEventListener("visibilitychange", handleChange);
  return () => document.removeEventListener("visibilitychange", handleChange);
}, []);

If the listener must read props (e.g. onTabAway(count) from parent) or multiple reactive values without going through state, useEffectEvent is the better fit:

const onTabAway = useEffectEvent(() => {
  onTabAwayProp(tabAwayCount);
});

useEffect(() => {
  const handleChange = () => {
    if (document.visibilityState !== "visible") onTabAway();
  };
  document.addEventListener("visibilitychange", handleChange);
  return () => document.removeEventListener("visibilitychange", handleChange);
}, []);

5. Button click — neither useEffect nor useEffectEvent

// ✅ user caused this; Rule #1 — event handler
<button onClick={() => saveDraft(title)}>Save</button>

useEffectEvent is not for JSX event handlers. Effects (and effect events) are for ongoing synchronization after render, not direct user actions.


6. Global store — useSyncExternalStore, not useEffectEvent

If the outside system is a store with subscribe + getSnapshot, use useSyncExternalStore. useEffectEvent is for values you read inside an effect’s callback, not for replacing store subscription.


Quick pick (effect vs effect event vs other)

ScenarioUse
Fetch / subscribe when id / roomId / filter changesuseEffect with that value in deps
Long-lived listener; only behavior on fire should see latest props/stateuseEffect + useEffectEvent
Listener only bumps state from previous stateuseEffect + functional setState
User clicked SaveonClick handler
Many components read same external storeuseSyncExternalStore
Stable wrapper without React 19 APIuseRef + saved.current = fn (see useRef)

useEffectEvent vs alternatives (quick comparison)

QuestionPrefer functional setStatePrefer ref + wrapperPrefer useEffectEvent
Only need latest state inside a stable listener?setN(n => n + 1)WorksWorks
Need latest props or callbacks in a subscription?AwkwardYesYes
Effect should not re-run when onSave changes?Manual ref syncYes
Calling “event” logic from render?Not allowed — effects/events only

useCallback in the dependency array still re-runs the effect when the callback identity changes — it does not give you a stable subscription by itself.


Pattern: interval that logs latest count

import { useEffect, useEffectEvent, useState } from "react";

function TickLogger() {
  const [count, setCount] = useState(0);

  const onTick = useEffectEvent(() => {
    console.log("latest count:", count);
  });

  useEffect(() => {
    const id = setInterval(() => onTick(), 1000);
    return () => clearInterval(id);
  }, []); // onTick is stable; body still sees fresh count

  return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}

Without useEffectEvent, either count lands in the dependency array (interval reset every tick) or the interval logs a stale count.


Pattern: external listener with latest callback prop

function ChatRoom({ roomId, onMessage }) {
  const onMessageEvent = useEffectEvent(onMessage);

  useEffect(() => {
    const socket = connect(roomId);
    socket.on("message", (msg) => onMessageEvent(msg));
    return () => socket.disconnect();
  }, [roomId]); // reconnect when room changes, not when onMessage identity changes
}

Parent can pass an inline onMessage without forcing a WebSocket teardown on every parent render.


Pattern: relationship to the ref wrapper (useRef lesson)

The useRef “stable listener, fresh closure” pattern keeps saved.current = handler in an effect and registers (e) => saved.current(e) once. useEffectEvent is the declarative form of that idea for non-rendering callbacks used inside effects.

useRef pattern          useEffectEvent
────────────────        ────────────────
saved.current = fn  →   useEffectEvent(fn)
wrapper calls .current   returned fn is the stable surface

When to reach for useEffectEvent (decision tree)

Effect or subscription needs values from render?

├─ Only updating React state from a timer/listener?
│     └─► functional setState (setX(x => ...)) if enough

├─ Subscribing once ([] deps) but need fresh props/callbacks?
│     ├─ Comfortable with ref + wrapper?
│     │     └─► useRef pattern (see useRef notes)
│     └─► useEffectEvent

├─ Effect should re-run when value changes (fetch when id changes)?
│     └─► put value in deps; don't use useEffectEvent to hide deps

└─ Need to call logic from render or JSX event directly?
      └─► normal function or useCallback — not useEffectEvent

Important: useEffectEvent is for code that belongs in effects (or other subscriptions), not for replacing event handlers on DOM elements.


Pitfalls (short)

PitfallWhy it hurts
Using it to avoid listing real dependencies the effect should react toHides when fetch/subscription should restart (e.g. userId)
Calling useEffectEvent result during renderUnsupported — stale rules differ from effects
Replacing useCallback on buttonsEvent handlers are not effect events
Passing effect event to memoized child as a propChildren may not update when you expect — keep events inside effects

Where to go next

  • What belongs in effect deps: useEffect — cleanup, ignore flag, functional setState in listeners
  • Manual ref equivalent: useRef — stable listener, fresh closure
  • Store subscription (Rule #4): useSyncExternalStore
  • Overview rules: Overview

Official docs: useEffectEvent and Separating Events from Effects in the React documentation (React 19+).