React useEffectEvent: Latest Props Without Re-Running

useEffectEvent has been a stable React Hook since React 19.2 (1 October 2025). It wraps a function so that, when an Effect calls it, the function reads the latest props and state without those values appearing in the Effect’s dependency array. Use it for the “event” part of an Effect: the notification, the log, the callback into app code. Keep the “synchronisation” part, the connect and disconnect, reactive as before.

What problem does it solve?

The React docs’ guide to separating events from Effects uses a chat room. The Effect connects to a room and, on the connected event, shows a notification in the current theme.

function ChatRoom({ roomId, theme }: { roomId: string; theme: 'light' | 'dark' }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      showNotification('Connected!', theme);
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, theme]);
  // ...
}

The Effect reads theme, so theme is a dependency, so toggling dark mode disconnects and reconnects the chat. The connection genuinely depends on roomId. It does not depend on theme at all; only the notification does. The React team’s own description of what people did about this: “most users just disable the lint rule and exclude the dependency.” Once the array lies, the linter can no longer tell you when a later edit adds a dependency that matters.

useEffectEvent splits the two concerns apart.

import { useEffect, useEffectEvent } from 'react';

function ChatRoom({ roomId, theme }: { roomId: string; theme: 'light' | 'dark' }) {
  const onConnected = useEffectEvent(() => {
    showNotification('Connected!', theme);
  });

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      onConnected();
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // all dependencies declared; Effect Events are not dependencies
  // ...
}

Now theme changes without a reconnect, and the array still tells the truth. The docs describe the wrapped function as an Effect Event: part of the Effect’s logic, but one that “always sees the latest values from render (like props and state)”, the same way a DOM event handler does.

What are the rules?

The reference page sets out four. The linter enforces three.

It is a Hook, so it goes at the top level of a component or a custom Hook, never inside a loop or a condition. The function it returns can be called from useEffect, useLayoutEffect, useInsertionEffect or another Effect Event in the same component. Calling it during render throws “A function wrapped in useEffectEvent can’t be called during rendering”. Calling it from a click handler, or passing it to a child as a prop, trips the lint rule instead: ”… is a function created with React Hook useEffectEvent, and can only be called from Effects and Effect Events”.

Do not put it in a dependency array. The returned function has no stable identity; the docs say it “intentionally changes on every render”, and the reasoning is worth quoting because it explains the whole design. Since you can only call an Effect Event locally and cannot pass it anywhere, “a stable identity would serve no purpose, and would actually mask bugs.” If you list one as a dependency anyway, your Effect re-runs on every render and the fault is visible immediately. The linter catches this case too, with “Functions returned from useEffectEvent must not be included in the dependency array”.

The fourth rule is the one the linter cannot check. If a value should re-run your Effect, keep it as a dependency. The reference page is blunt: “Do not use useEffectEvent to avoid specifying dependencies in your Effect’s dependency array. This hides bugs and makes your code harder to understand.”

useEffectEvent vs useCallback vs a ref

Before 19.2, code that needed “the latest value without re-subscribing” used one of two workarounds.

The first is useCallback. It does not work for this problem. A memoised callback that reads theme must list theme in its own dependency array, so its identity changes when theme changes, so the Effect that depends on the callback re-runs anyway. You have moved the reconnect one level up, not removed it.

The second is the latest-ref pattern: store the value or callback in a useRef, write to ref.current in an Effect or during render, and read ref.current from inside the subscription. This works. useEffectEvent is that pattern done by React: the value is always the latest committed render, the identity rule stops anyone from accidentally depending on it, and the linter knows what it is. If you have a useLatest or useEventCallback helper in a shared utilities folder, this is what it was approximating.

Where useCallback remains the right tool is everything the rules above forbid: a handler you pass to a child, a function you hand to another Hook, anything called from a user event. The troubleshooting section says so: for those cases, “use a regular function or useCallback instead”.

How should a custom Hook use it?

The restriction that an Effect Event cannot be passed to another Hook has a consequence for library-style code. The wrong shape is for the component to wrap its callback in useEffectEvent and pass the result to useTimer(onTick, 1000). The right shape is for the Hook to accept a plain callback and do the wrapping itself, next to the Effect that fires it.

import { useEffect, useEffectEvent } from 'react';

export function useInterval(callback: () => void, delay: number | null) {
  const onTick = useEffectEvent(callback);

  useEffect(() => {
    if (delay === null) {
      return;
    }
    const id = setInterval(() => {
      onTick();
    }, delay);
    return () => clearInterval(id);
  }, [delay]);
}

The interval is created once per delay. A new callback on every render (an inline arrow in the component, say) never resets it, because onTick always calls the latest one. The same shape fits a useWindowEvent, useOnResize or useWebSocket Hook: the Effect Event sits inside the Hook, the Effect’s dependencies shrink to the values that really re-subscribe, and the caller passes an ordinary function.

Which eslint-plugin-react-hooks version do you need?

The linting half shipped alongside React 19.2 on 1 October 2025 as eslint-plugin-react-hooks 6.1.0. The plugin’s changelog lists the relevant change under “New Violations”: calling useEffectEvent functions in arbitrary closures is disallowed. The same release made flat config the default recommended preset, moved the old format to recommended-legacy, and requires Node.js 18 or newer. 6.0.0 was published by mistake and untagged; 6.1.0 is the first real 6.x.

6.1.1 (3 October 2025) fixed recommended for flat config users. It is also the version the exhaustive-deps docs name for the react-hooks.additionalEffectHooks shared setting, a regex for custom Effect-like Hooks that exhaustive-deps and rules-of-hooks should both treat as Effects (the changelog itself lists that option under 6.1.0). 7.0.0 (8 October 2025) slimmed the presets to two: recommended for both legacy and flat configs, with the React Compiler-derived rules enabled by default, and recommended-latest for the experimental ones. 7.0.1 closed a gap by disallowing an inline useEffectEvent value as a JSX prop. As of October 2026 the current release is 7.1.1.

If your react-hooks/exhaustive-deps rule still wants to add an Effect Event to a dependency array, your plugin predates 6.1.0. Upgrade it before you adopt the Hook, or the linter and the runtime will disagree about what the right code looks like.

When is it the wrong tool?

Three cases.

The first is any place where the value should trigger the Effect. The docs’ example is a page-view log: wrapping log(pageUrl) in an Effect Event and calling it from an Effect with [] dependencies means a client-side route change to a new URL logs nothing. pageUrl belongs in the array. The test is to ask, for each value the Effect reads, whether a change in it should cause the external system to be re-synchronised. If yes, it is a dependency. If no, it goes in the Effect Event.

The second is data fetching. An Effect that fetches on [userId] and reads pageSize from inside an Effect Event will not refetch when pageSize changes, and nothing will warn you, because the linter has been told that is intended.

The third is version-specific. The React 19.3.0 changelog (9 September 2026) includes a fix for useEffectEvent so that it reads the latest values in forwardRef and memo components. If an Effect Event inside a memoised component on 19.2.x reads stale values, upgrade before reaching for a ref.

Our view is to adopt it in two passes. First grep for eslint-disable comments on exhaustive-deps and for useRef instances whose only job is to hold a latest callback, and convert the ones where the value really is incidental to the subscription. Second, rewrite your own subscription Hooks to the useInterval shape above so that callers can pass inline functions. Do not hunt for further uses after that; an Effect without an Effect Event is still the normal case. We covered the other headline addition in 19.2 in our post on the React Activity component, and the compiler-derived rules that the same lint plugin now ships by default in our guide to adopting the React Compiler incrementally.

Whoooop upgrades and maintains React codebases for UK clients, including the audits that find the suppressed lint rules and the stale-closure bugs behind them. Our React development work covers that, from a one-off review to taking over an application.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch