React useEffectEvent Tutorial: Stop Effect Re-Runs (2026)
June 8, 2026
useEffectEvent, stable since React 19.2, lets an Effect read the latest props and state without listing them as dependencies — so a theme or settings change no longer tears down your WebSocket or timer. This hands-on React useEffectEvent tutorial proves the fix with runnable tests.
TL;DR
This React useEffectEvent tutorial builds a small live-metrics feed component three ways: the naive version that reconnects on every theme change, the lint-suppressed version that shows stale data, and the useEffectEvent version that does neither. Each behavior is pinned down by a Vitest test you can run yourself, and the lint messages quoted along the way are verbatim eslint-plugin-react-hooks@7.1.1 output. Total time: about 20 minutes. Everything was executed against react@19.2.7 on the day of writing.
What you'll learn
- Why
useEffectre-runs on every prop change — and when that's a bug rather than a feature - Why suppressing
exhaustive-depstrades reconnect churn for stale closures - How to fix both problems with
useEffectEvent, step by step - How to wire the linter so it enforces the Effect Event rules for you
- When to reach for
useEffectEventvsuseCallback(decision table) - How to verify the behavior with tests instead of trusting a blog post
Prerequisites
- Node.js 24 LTS (Node 20.19+ or 22.12+ also works — that's the floor Vite 8 requires if you later drop the component into a Vite app1)
react@19.2.7andreact-dom@19.2.7—useEffectEventshipped as a stable API in React 19.2 (October 1, 2025)2, so any 19.2.x works; this post pins what's current on npm3- TypeScript 6.0.3, Vitest 4.1.8,
@testing-library/react@16.3.2, jsdom 29.1.1 - ESLint 10.4.1 with
eslint-plugin-react-hooks@7.1.1andtypescript-eslint@8.60.1
Set up a minimal project (no dev server needed — the proof harness runs headlessly):
mkdir metrics-feed && cd metrics-feed
npm init -y
npm install --save-exact react@19.2.7 react-dom@19.2.7 \
typescript@6.0.3 @types/react@19.2.17 @types/react-dom@19.2.3 \
vitest@4.1.8 @testing-library/react@16.3.2 jsdom@29.1.1 \
eslint@10.4.1 eslint-plugin-react-hooks@7.1.1 typescript-eslint@8.60.1
Set "type": "module" in package.json, plus three scripts: "test": "vitest run", "typecheck": "tsc --noEmit", "lint": "eslint src". If you'd rather work inside a full app, the standard scaffold is npm create vite@latest metrics-feed -- --template react-ts (create-vite 9.0.7)1 — the component code below is identical either way.
vitest.config.ts:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
},
});
tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vitest/globals"]
},
"include": ["src", "tests"]
}
Step 1 — A fake connection you can observe
Why does my useEffect re-run on every prop change? Because every value the Effect body reads must be a dependency, and React re-synchronizes the Effect whenever any dependency changes. To see that happen, we need an external system whose lifecycle is visible. This module records every connect and disconnect in an exported log — the UI can render it, and tests can assert on it.
src/connection.ts:
export type ConnectionEvent = 'connected';
export interface Connection {
on(event: ConnectionEvent, handler: () => void): void;
connect(): void;
disconnect(): void;
}
// Visible to the UI and to tests: every connect/disconnect is recorded here.
export const connectionLog: string[] = [];
const ACK_DELAY_MS = 300;
export function createConnection(source: string): Connection {
let onConnected: (() => void) | null = null;
let ackTimer: ReturnType<typeof setTimeout> | null = null;
return {
on(event, handler) {
if (event === 'connected') {
onConnected = handler;
}
},
connect() {
connectionLog.push(`connect:${source}`);
ackTimer = setTimeout(() => {
onConnected?.();
}, ACK_DELAY_MS);
},
disconnect() {
if (ackTimer !== null) {
clearTimeout(ackTimer);
}
connectionLog.push(`disconnect:${source}`);
},
};
}
The 300 ms acknowledgment delay matters: it creates a window where props can change between connecting and the "connected" callback firing — exactly where stale closures hide.
Step 2 — The naive version: useEffect re-runs on every prop change
The component subscribes to a metrics source and shows a toast when connected. The toast mentions the current theme. One Effect, two values — but they're different in kind: source is Effect logic (changing it should reconnect), while theme is only read by the event that fires when the connection acks.
src/MetricsFeedNaive.tsx:
import { useEffect, useState } from 'react';
import { createConnection } from './connection';
type Theme = 'dark' | 'light';
export function MetricsFeedNaive({ source, theme }: { source: string; theme: Theme }) {
const [toast, setToast] = useState('');
useEffect(() => {
const connection = createConnection(source);
connection.on('connected', () => {
setToast(`Connected to ${source} (${theme} theme)`);
});
connection.connect();
return () => {
connection.disconnect();
};
}, [source, theme]); // theme is required here -- and that is the bug
return (
<section className={theme}>
<h2>Live metrics: {source}</h2>
{toast && <p role="status">{toast}</p>}
</section>
);
}
The dependency array is correct by the rules — theme is read inside the Effect, so exhaustive-deps requires it. And that's precisely the problem: toggling the theme tears the connection down and rebuilds it.
Time to prove it. Create tests/feed.test.tsx with this scaffold — it imports all three component variants up front (you'll create the remaining two in Steps 3 and 4; the suite runs in Verification), resets the log before each test, and uses fake timers so we control exactly when the 300 ms ack fires:
import { act, cleanup, render, screen } from '@testing-library/react';
import { useEffectEvent } from 'react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { connectionLog } from '../src/connection';
import { MetricsFeed } from '../src/MetricsFeed';
import { MetricsFeedNaive } from '../src/MetricsFeedNaive';
import { MetricsFeedStale } from '../src/MetricsFeedStale';
beforeEach(() => {
vi.useFakeTimers();
connectionLog.length = 0;
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
The first test pins the churn down:
test('naive: a theme change tears down and recreates the connection', () => {
const { rerender } = render(<MetricsFeedNaive source="api" theme="dark" />);
expect(connectionLog).toEqual(['connect:api']);
rerender(<MetricsFeedNaive source="api" theme="light" />);
expect(connectionLog).toEqual(['connect:api', 'disconnect:api', 'connect:api']);
});
Swap the fake for a real WebSocket and this is the classic "my socket reconnects on every render" bug — a settings toggle, a locale switch, or any cosmetic prop drops and rebuilds the connection underneath your users.
Step 3 — The trap: suppressing the lint rule creates a stale closure
The tempting "fix" is to drop theme from the array and silence the linter. Copy MetricsFeedNaive.tsx to src/MetricsFeedStale.tsx, rename the component to MetricsFeedStale, and change only the closing lines of the Effect:
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [source]); // theme omitted: no reconnect churn, but the closure goes stale
The reconnect churn disappears — and a different bug appears. The Effect ran once, when theme was dark. The connected handler closed over that first render's theme and never sees another one. The test proves the toast lies:
test('stale: suppressing the dep keeps the connection but shows the OLD theme', () => {
const { rerender } = render(<MetricsFeedStale source="api" theme="dark" />);
rerender(<MetricsFeedStale source="api" theme="light" />);
expect(connectionLog).toEqual(['connect:api']); // no churn...
act(() => {
vi.advanceTimersByTime(300);
});
// ...but the closure captured the first render's theme
expect(screen.getByRole('status').textContent).toBe('Connected to api (dark theme)');
});
The UI is in light mode; the toast says dark. The React team's release notes call out exactly this pattern: "most users just disable the lint rule and exclude the dependency. But that can lead to bugs."2
Step 4 — The fix: separate the event from the Effect with useEffectEvent
useEffectEvent extracts the non-reactive part into an Effect Event — a function that, per the docs, "always accesses the latest committed values from render at the time of the call" and is excluded from Effect dependencies.4 (The thinking behind the split is covered in depth in React's "Separating Events from Effects" guide.5) The release notes put it plainly: "Similar to DOM events, Effect Events always 'see' the latest props and state."2
src/MetricsFeed.tsx:
import { useEffect, useEffectEvent, useState } from 'react';
import { createConnection } from './connection';
type Theme = 'dark' | 'light';
export function MetricsFeed({ source, theme }: { source: string; theme: Theme }) {
const [toast, setToast] = useState('');
const onConnected = useEffectEvent((connectedTo: string) => {
setToast(`Connected to ${connectedTo} (${theme} theme)`);
});
useEffect(() => {
const connection = createConnection(source);
connection.on('connected', () => {
onConnected(source);
});
connection.connect();
return () => {
connection.disconnect();
};
}, [source]);
return (
<section className={theme}>
<h2>Live metrics: {source}</h2>
{toast && <p role="status">{toast}</p>}
</section>
);
}
Three things to notice. The reactive value (source) stays a dependency — changing it still reconnects. The non-reactive value (theme) moved into the Effect Event and is read fresh at call time. And onConnected itself is not in the array: Effect Events are excluded from dependencies by design, and the linter knows it.4
Both halves of the fix are now testable:
test('useEffectEvent: no reconnect AND the latest theme', () => {
const { rerender } = render(<MetricsFeed source="api" theme="dark" />);
rerender(<MetricsFeed source="api" theme="light" />);
expect(connectionLog).toEqual(['connect:api']); // single connection
act(() => {
vi.advanceTimersByTime(300);
});
expect(screen.getByRole('status').textContent).toBe('Connected to api (light theme)');
});
test('useEffectEvent: changing source still reconnects (stays reactive)', () => {
const { rerender } = render(<MetricsFeed source="api" theme="dark" />);
rerender(<MetricsFeed source="db" theme="dark" />);
expect(connectionLog).toEqual(['connect:api', 'disconnect:api', 'connect:db']);
});
One connection, fresh theme, and reactivity preserved where it belongs.
Step 5 — Wire the linter so the rules enforce themselves
The Effect Event restrictions are enforced by eslint-plugin-react-hooks6 — but only if your config actually loads. Version 7.1.1 exposes both legacy and flat presets, and ESLint 10 removed the legacy .eslintrc system entirely7 — flat config is the only one it accepts — so you must reach for the flat presets. The package README points flat-config users at configs.flat.recommended; the separate recommended-latest preset is for "bleeding edge experimental compiler rules," so stick with recommended here.
eslint.config.js:
import reactHooks from 'eslint-plugin-react-hooks';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
files: ['src/**/*.{ts,tsx}'],
extends: [
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
],
},
);
With that in place, npx eslint src passes on the Step 4 component — exhaustive-deps asks for neither theme nor onConnected. Now break the rules on purpose. List the Effect Event as a dependency:
useEffect(() => {
onLog();
}, [onLog]); // effect event listed as a dependency
and the plugin answers with a warning (rule: react-hooks/exhaustive-deps):
Functions returned from `useEffectEvent` must not be included in the
dependency array. Remove `onLog` from the list
That's not pedantry. Effect Event identity intentionally changes on every render — the docs describe the unstable identity as "a runtime assertion" — so depending on it would re-run your Effect every render.4 Call one from a click handler instead:
return <button onClick={() => onLog()}>log</button>;
and you get a hard error (rule: react-hooks/rules-of-hooks):
`onLog` is a function created with React Hook "useEffectEvent", and can
only be called from Effects and Effect Events in the same component
Both messages above are verbatim ESLint output from eslint-plugin-react-hooks@7.1.1.
useEffectEvent vs useCallback: which one do you need?
Is useEffectEvent a replacement for useCallback? No — they solve opposite problems. useCallback gives you a stable identity you can pass around; an Effect Event gives you fresh values inside Effects and can't leave the component. The docs are explicit: for callbacks you pass to children or call from event handlers, "use a regular function or useCallback instead."4
| You need... | Reach for |
|---|---|
| Read latest props/state inside an Effect without re-running it | useEffectEvent |
| A function with stable identity to pass to a memoized child | useCallback |
| The Effect to re-run when a value changes (it's Effect logic) | Keep it in the dependency array |
| A callback for an event handler or another component/Hook | Regular function or useCallback |
To silence exhaustive-deps because the dep list "feels wrong" | None of these — restructure; hiding deps hides bugs4 |
The boundary rule: Effect Events can only be declared in the same component or Hook as their Effect, can't be passed to other components or Hooks, and can't be called during render.4 They do work inside custom Hooks — the docs' useInterval wraps the incoming callback in an Effect Event so a new callback each render doesn't reset the interval.4
Verification
One last test completes the suite — the render-time guard. Add it to tests/feed.test.tsx:
function CallsDuringRender() {
const onPing = useEffectEvent(() => {});
onPing(); // wrong on purpose
return null;
}
test('calling an effect event during render throws', () => {
expect(() => render(<CallsDuringRender />)).toThrowError();
});
Then run the whole proof harness:
npx tsc --noEmit && npx vitest run
Expected output (abbreviated):
✓ tests/feed.test.tsx (5 tests)
Test Files 1 passed (1)
Tests 5 passed (5)
Five passing tests: reconnect churn in the naive version, the stale toast in the suppressed version, single-connection-plus-fresh-theme with useEffectEvent, reconnect-on-source-change, and the render-time guard above. Then npx eslint src for a clean lint pass.
Troubleshooting
"A function wrapped in useEffectEvent can't be called during rendering." — You called the Effect Event in the component body. This is a runtime error (verified on react@19.2.7; our fifth test asserts it). Move the call into useEffect — or if the logic must run during render, it isn't an event; don't wrap it.4
"Functions returned from useEffectEvent must not be included in the dependency array." — react-hooks/exhaustive-deps warning. Remove the Effect Event from the deps; it's excluded by design.4
"...can only be called from Effects and Effect Events in the same component." — react-hooks/rules-of-hooks error. You called the Effect Event from an event handler or passed it to a child. Use a regular function or useCallback for those paths.4
ESLint crashes with: A config object has a "plugins" key defined as an array of strings. — You loaded a legacy-format preset (such as reactHooks.configs['recommended-latest'] in 7.1.1) into flat-config ESLint. Use reactHooks.configs.flat.recommended as in Step 5.
TypeScript: Module '"react"' has no exported member 'useEffectEvent'. — Your react or @types/react predates 19.2. Upgrade both (react@19.2.x runtime and @types/react@19.2.x — the two are versioned independently on npm).
Next steps
If useEffect fundamentals are the shaky part, start with our useEffect guide on dependency arrays and cleanup and then revisit this React useEffectEvent tutorial. For more of React 19's surface in practice, see Server Actions and optimistic UI with React 19 in Next.js 16; useEffectEvent also features in our React interview prep rundown of 19.2 features. From here, two natural extensions: wrap the pattern into a reusable useConnection(source) custom Hook (Effect Events work inside custom Hooks4), and explore <Activity />, the other headline API of React 19.2.2
Footnotes
-
Vite — Getting Started — scaffold command and the Node.js 20.19+ / 22.12+ requirement. ↩ ↩2
-
React 19.2 release post (October 1, 2025) —
useEffectEvent,<Activity />,cacheSignal, and the eslint-plugin-react-hooks v6 notes. ↩ ↩2 ↩3 ↩4 -
react on npm — 19.2.7 was the latest stable at the time of writing (published June 1, 2026, per the npm registry). ↩
-
useEffectEvent — official React reference — API, caveats, identity semantics, and troubleshooting entries. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11
-
Separating Events from Effects — react.dev — the conceptual deep dive behind Effect Events. ↩
-
eslint-plugin-react-hooks — official rules reference — the recommended preset,
exhaustive-deps, andrules-of-hooks. ↩ -
ESLint v10.0.0 released — the legacy
.eslintrcconfiguration system was removed in v10; only flat config is supported. ↩