---
title: 'React useEffectEvent Tutorial: Stop Effect Re-Runs (2026)'
description: 'Hands-on React useEffectEvent tutorial: fix stale closures, stop useEffect re-running on prop changes, and migrate from useCallback. Runnable 2026 code.'
url: https://nerdleveltech.com/react-useeffectevent-stop-effect-reruns-tutorial
language: en
date: '2026-06-08'
category: web-frontend
tags:
  - react
  - useeffectevent
  - useeffect
  - react-19
  - hooks
  - typescript
  - eslint
  - tutorial
  - web-frontend
markdown_url: https://nerdleveltech.com/react-useeffectevent-stop-effect-reruns-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/react-useeffectevent-stop-effect-reruns-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# React useEffectEvent Tutorial: Stop Effect Re-Runs (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 `useEffect` re-runs on every prop change — and when that's a bug rather than a feature
- Why suppressing `exhaustive-deps` trades 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 `useEffectEvent` vs `useCallback` (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 app[^5])
- `react@19.2.7` and `react-dom@19.2.7` — `useEffectEvent` shipped 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 npm[^4]
- 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.1` and `typescript-eslint@8.60.1`

Set up a minimal project (no dev server needed — the proof harness runs headlessly):

```bash
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)[^5] — the component code below is identical either way.

`vitest.config.ts`:

```ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
    globals: true,
  },
});
```

`tsconfig.json`:

```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`:

```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`:

```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:

```tsx
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:

```tsx
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:

```tsx
    // 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:

```tsx
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.[^1] (The thinking behind the split is covered in depth in React's "Separating Events from Effects" guide.[^6]) The release notes put it plainly: "Similar to DOM events, Effect Events always 'see' the latest props and state."[^2]

`src/MetricsFeed.tsx`:

```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.[^1]

Both halves of the fix are now testable:

```tsx
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-hooks`[^3] — 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 entirely[^7] — 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`:

```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:

```tsx
  useEffect(() => {
    onLog();
  }, [onLog]); // effect event listed as a dependency
```

and the plugin answers with a warning (rule: `react-hooks/exhaustive-deps`):

```text
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.[^1] Call one from a click handler instead:

```tsx
  return <button onClick={() => onLog()}>log</button>;
```

and you get a hard error (rule: `react-hooks/rules-of-hooks`):

```text
`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."[^1]

| 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 bugs[^1] |

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.[^1] 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.[^1]

## Verification

One last test completes the suite — the render-time guard. Add it to `tests/feed.test.tsx`:

```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:

```bash
npx tsc --noEmit && npx vitest run
```

Expected output (abbreviated):

```text
 ✓ 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.[^1]

**"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.[^1]

**"...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.[^1]

**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](https://nerdleveltech.com/react-useeffect-2025) 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](https://nerdleveltech.com/nextjs-16-server-actions-react-19-optimistic-ui-tutorial); `useEffectEvent` also features in our [React interview prep rundown of 19.2 features](https://nerdleveltech.com/mastering-the-react-interview-top-questions-and-best-practices). From here, two natural extensions: wrap the pattern into a reusable `useConnection(source)` custom Hook (Effect Events work inside custom Hooks[^1]), and explore `<Activity />`, the other headline API of React 19.2.[^2]

## Related reads

- [React Activity Component Tutorial: Stateful Tabs (2026)](https://nerdleveltech.com/react-activity-component-tutorial)

[^1]: [useEffectEvent — official React reference](https://react.dev/reference/react/useEffectEvent) — API, caveats, identity semantics, and troubleshooting entries.
[^2]: [React 19.2 release post (October 1, 2025)](https://react.dev/blog/2025/10/01/react-19-2) — `useEffectEvent`, `<Activity />`, `cacheSignal`, and the eslint-plugin-react-hooks v6 notes.
[^3]: [eslint-plugin-react-hooks — official rules reference](https://react.dev/reference/eslint-plugin-react-hooks) — the recommended preset, `exhaustive-deps`, and `rules-of-hooks`.
[^4]: [react on npm](https://www.npmjs.com/package/react) — 19.2.7 was the latest stable at the time of writing (published June 1, 2026, per the npm registry).
[^5]: [Vite — Getting Started](https://vite.dev/guide/) — scaffold command and the Node.js 20.19+ / 22.12+ requirement.
[^6]: [Separating Events from Effects — react.dev](https://react.dev/learn/separating-events-from-effects) — the conceptual deep dive behind Effect Events.
[^7]: [ESLint v10.0.0 released](https://eslint.org/blog/2026/02/eslint-v10.0.0-released/) — the legacy `.eslintrc` configuration system was removed in v10; only flat config is supported.
