useEffect & Effects
What an effect is for
Section titled “What an effect is for”useEffect is the most misunderstood hook. It is not a general “run code after render” bucket. Its one job is to synchronize your component with an external system — something outside React’s control: a network connection, a browser API, a third-party widget, a subscription, a timer.
useEffect(() => { const connection = createConnection(roomId); // set up external system connection.connect(); return () => connection.disconnect(); // clean it up}, [roomId]); // re-sync when roomId changesRead that as: “keep the connection in sync with roomId.” When roomId changes, React runs the cleanup for the old value, then the effect for the new one. When the component unmounts, it runs the final cleanup.
Dependencies and cleanup
Section titled “Dependencies and cleanup”The dependency array lists every reactive value (prop, state, or derived value) the effect reads. React re-runs the effect whenever one of them changes.
[dep1, dep2]— re-run when these change.[]— run once on mount, cleanup on unmount.- omitted — run after every render (rarely what you want).
The cleanup function you return must undo whatever the effect set up — disconnect, clear the timer, remove the listener. Skipping cleanup is the #1 source of bugs: duplicate connections, leaks, and listeners firing after unmount.
flowchart LR dep["dependency changes"] --> cleanup["run cleanup for old value"] cleanup --> setup["run effect for new value"] unmount["component unmounts"] --> final["run final cleanup"]
Do not fight the linter that fills the dependency array. If a dependency is inconvenient, the fix is almost never to lie about the array — it’s to restructure (move the value inside the effect, use an updater, or memoize).
You probably do not need an effect
Section titled “You probably do not need an effect”This is the most important lesson in the whole module. Effects are for external systems. If you’re using an effect to transform data for rendering or to respond to a user event, you almost certainly don’t need one — and using one makes the code slower and buggier.
Don’t sync derived state with an effect. Compute it during render.
// 🔴 Avoid: redundant state + an effect to keep it in sync.const [fullName, setFullName] = useState('');useEffect(() => { setFullName(firstName + ' ' + lastName);}, [firstName, lastName]);
// ✅ Good: derive it during render — no state, no effect.const fullName = firstName + ' ' + lastName;For an expensive derivation, wrap it in useMemo — still no effect, no extra state:
// ✅ Cached derivation, no effect.const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);Don’t use an effect to handle a user event. Logic that should run because the user did something belongs in the event handler, where you know exactly what happened.
// 🔴 Avoid: reacting to a state change with an effect to send analytics.useEffect(() => { if (submitted) post('/analytics', { event: 'submit' });}, [submitted]);
// ✅ Good: do it in the handler that caused the submit.function handleSubmit() { setSubmitted(true); post('/analytics', { event: 'submit' });}The quick test: “Is this synchronizing with something outside React?” If yes, effect. If it’s deriving data or reacting to an interaction, no effect.
Effect ordering: mount, unmount, and dependency changes
Section titled “Effect ordering: mount, unmount, and dependency changes”Once a tree has effects at more than one level, the order they fire in matters — a parent effect that assumes a child’s effect has already run (or vice versa) will read stale state. React’s actual order, proven below:
- On mount, passive effects commit bottom-up: every child’s effect runs before its parent’s.
- On unmount, cleanup runs top-down — the reverse of mount order: the parent’s cleanup runs before the child’s.
- On a dependency change, a single effect’s own cleanup (for the old value) always runs immediately before its own new setup (for the new value) — never batched with any other effect.
import { useEffect } from 'react';import { render, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
function Child({ log }: { log: string[] }) { useEffect(() => { log.push('child effect'); return () => { log.push('child cleanup'); }; }); return <span>child</span>;}
function Parent({ log }: { log: string[] }) { useEffect(() => { log.push('parent effect'); return () => { log.push('parent cleanup'); }; }); return <Child log={log} />;}
test('mount runs child effects before parent effects', () => { const log: string[] = []; render(<Parent log={log} />); // React commits passive effects bottom-up: children first, then parent. expect(log).toEqual(['child effect', 'parent effect']);});
test('unmount runs parent cleanup before child cleanup (reverse of mount order)', () => { const log: string[] = []; const { unmount } = render(<Parent log={log} />); log.length = 0; unmount(); expect(log).toEqual(['parent cleanup', 'child cleanup']);});
test('on a dependency change, an effect\'s own cleanup runs before its new setup', () => { const log: string[] = []; function Timer({ id }: { id: number }) { useEffect(() => { log.push(`setup ${id}`); return () => { log.push(`cleanup ${id}`); }; }, [id]); return null; } const { rerender } = render(<Timer id={1} />); log.length = 0; rerender(<Timer id={2} />); expect(log).toEqual(['cleanup 1', 'setup 2']);});Real run, in a Vite + React 19.3 + Vitest + React Testing Library probe:
✓ mount runs child effects before parent effects✓ unmount runs parent cleanup before child cleanup (reverse of mount order)✓ on a dependency change, an effect's own cleanup runs before its new setup
Test Files 1 passed (1) Tests 3 passed (3)useEffectEvent: the stale-closure problem, solved (React 19.2)
Section titled “useEffectEvent: the stale-closure problem, solved (React 19.2)”Go back to the connection example. Now say the effect also needs to log something that changes often — say, a theme — without re-connecting every time theme changes:
function ChatRoom({ roomId, theme }) { useEffect(() => { const connection = createConnection(roomId); connection.on('connected', () => { showNotification('Connected!', theme); // reads theme }); connection.connect(); return () => connection.disconnect(); }, [roomId, theme]); // 🔴 now reconnects every time theme changes too}Add theme to the deps and the connection resets on every theme toggle — reconnecting has nothing to do with theming. Leave it out and the linter flags a real bug: showNotification would close over a stale theme from whenever the effect last ran. Before 19.2, the fix was awkward restructuring (a ref that mirrors the latest value). useEffectEvent is the tool React shipped specifically for this: it lets you extract the non-reactive part of an effect into an “Effect Event” that always sees the latest props/state, without being itself a reactive dependency.
import { useEffect, useEffectEvent, useState } from 'react';
function Timer({ onTick }: { onTick: () => void }) { const [count, setCount] = useState(0); const [increment, setIncrement] = useState(1);
// useEffectEvent: reads the LATEST `increment` without being reactive. const onTickEvent = useEffectEvent(() => { setCount((c) => c + increment); onTick(); });
useEffect(() => { const id = setInterval(onTickEvent, 1000); return () => clearInterval(id); // Empty deps: the interval is set up once. `increment` is read through // useEffectEvent, so it is intentionally NOT a dependency here. }, []);
return ( <div> <output data-testid="count">{count}</output> <button onClick={() => setIncrement((i) => i + 1)}>bump increment</button> </div> );}
export default Timer;The function returned by useEffectEvent is not reactive — its identity is stable, and calling it always runs with the component’s latest props and state, as if it were declared right there in the effect. That is exactly why it must never be added to the effect’s dependency array: doing so would defeat the point (making the effect re-run on every render) and the official ESLint rule blocks it outright. Effect Events may only be declared in the same component or hook as the effect that calls them — the linter enforces that restriction too.
import { useEffect, useEffectEvent, useState } from 'react';import { act, render, cleanup } from '@testing-library/react';import { afterEach, expect, test, vi } from 'vitest';
afterEach(cleanup);
function Timer({ onTick }: { onTick: () => void }) { const [count, setCount] = useState(0); const [increment, setIncrement] = useState(1);
const onTickEvent = useEffectEvent(() => { setCount((c) => c + increment); onTick(); });
useEffect(() => { const id = setInterval(onTickEvent, 1000); return () => clearInterval(id); }, []);
return ( <div> <output data-testid="count">{count}</output> <button onClick={() => setIncrement((i) => i + 1)}>bump increment</button> </div> );}
test('useEffectEvent reads the latest increment without re-running the effect (no interval reset)', () => { vi.useFakeTimers(); const onTick = vi.fn(); const { getByTestId, getByRole } = render(<Timer onTick={onTick} />);
act(() => { vi.advanceTimersByTime(1000); // tick with increment=1 }); expect(getByTestId('count').textContent).toBe('1');
act(() => { getByRole('button').click(); // increment becomes 2 — if the effect had }); // re-run, the interval would reset here.
act(() => { vi.advanceTimersByTime(1000); // tick with increment=2, same interval }); expect(getByTestId('count').textContent).toBe('3'); // 1 + 2, not reset to 0 expect(onTick).toHaveBeenCalledTimes(2); // interval never got torn down/rebuilt
vi.useRealTimers();});✓ useEffectEvent reads the latest increment without re-running the effect (no interval reset)
Test Files 1 passed (1) Tests 1 passed (1)Real ESLint output ([email protected], the successor to the v6 line the compiler rules shipped in) for the version that does put the Effect Event in the deps array:
warning Functions returned from `useEffectEvent` must not be included in the dependency array.Remove `onTickEvent` from the list react-hooks/exhaustive-depsuseEffectEvent shipped as a stable feature in React 19.2 (October 2025) — the React 19.2 announcement lists it alongside <Activity> and React Performance Tracks. If you’re on an earlier version, the ref-mirroring workaround is still the correct fallback.
Fetch-in-effect: the race condition, and AbortController
Section titled “Fetch-in-effect: the race condition, and AbortController”Fetching directly inside an effect has a classic bug: if userId changes quickly (e.g. clicking through a list), an earlier, slower request can resolve after a later, faster one — and overwrite fresh state with stale data. Returning an AbortController.abort() call from the cleanup fixes it: the stale request’s promise rejects instead of resolving into state.
import { useEffect, useState } from 'react';
declare function fetchUser(userId: string, signal: AbortSignal): Promise<string>;
function Profile({ userId }: { userId: string }) { const [name, setName] = useState('loading'); useEffect(() => { const controller = new AbortController(); fetchUser(userId, controller.signal) .then((result: string) => setName(result)) .catch((err: Error) => { if (err.name !== 'AbortError') throw err; // ignore our own abort }); return () => controller.abort(); // cancel the in-flight request }, [userId]); return <output>{name}</output>;}
export default Profile;Proven with a fake “slow” request that starts first but would resolve last:
import { useEffect, useState } from 'react';import { act, render, cleanup, screen, waitFor } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
// Simulates a slow-then-fast response: the FIRST request resolves LAST.function fakeFetchUser(id: string, signal: AbortSignal): Promise<string> { const delay = id === 'slow' ? 50 : 10; return new Promise((resolve, reject) => { const t = setTimeout(() => resolve(`user:${id}`), delay); signal.addEventListener('abort', () => { clearTimeout(t); reject(new DOMException('Aborted', 'AbortError')); }); });}
function Profile({ userId }: { userId: string }) { const [name, setName] = useState('loading'); useEffect(() => { const controller = new AbortController(); fakeFetchUser(userId, controller.signal) .then((result) => setName(result)) .catch((err) => { if (err.name !== 'AbortError') throw err; }); return () => controller.abort(); }, [userId]); return <output data-testid="name">{name}</output>;}
test('AbortController prevents the stale (slow) response from overwriting the fresh one', async () => { const { rerender } = render(<Profile userId="slow" />); // Switch to a fast request before the slow one resolves — without abort, // the slow response would land last and overwrite the fresh state. await act(async () => { rerender(<Profile userId="fast" />); }); await waitFor(() => expect(screen.getByTestId('name').textContent).toBe('user:fast')); // Give the (aborted) slow request's original timer a chance to fire too. await new Promise((r) => setTimeout(r, 60)); expect(screen.getByTestId('name').textContent).toBe('user:fast');});✓ AbortController prevents the stale (slow) response from overwriting the fresh one
Test Files 1 passed (1) Tests 1 passed (1)Without the AbortController, this test fails: the slow request’s .then still fires 40ms after the fast one and leaves name back at user:slow.
Mental Model
Section titled “Mental Model”An effect is not a lifecycle hook attached to the component’s births and deaths — it is a synchronization loop attached to a single reactive value (or set of values). Think of React re-running “cleanup old value, set up new value” every time any dependency changes, mount and unmount being just the first setup and the final cleanup of that same loop. useEffectEvent exists because “read the latest value” and “re-synchronize” are two different needs that used to be forced into the same dependency array.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Deriving state during render instead of an effect | Fewer renders, no redundant state | None — always prefer this when possible |
useEffectEvent for non-reactive reads | No stale closures, no unnecessary re-syncs | Only available since React 19.2; can’t be called outside its own effect/component |
AbortController in cleanup | No race conditions overwriting fresh state with stale data | Every fetch-in-effect needs this boilerplate, or a data-fetching library that does it for you |
| Omitting the dependency array entirely | Simple to write | Runs after every render — usually the wrong tool |
Common Misconceptions
Section titled “Common Misconceptions”- “I can just add the value to the dependency array to silence the linter.” Sometimes that’s correct. Often it’s a sign the effect is doing two unrelated things (reconnect vs. notify) that should be split, or that the value should be read through
useEffectEventinstead. - “Effects run in the order they’re written in one component.” True within one component, but across a tree, mount order is child-before-parent and unmount order is parent-before-child — proven above.
- “
useEffectEventdeps get auto-filled like a normal function.” The opposite: including the Effect Event itself in the array is an ESLint error, precisely because its whole purpose is to not be reactive. - “Aborting a fetch is optional cleanup.” Without it, a component that changes its data dependency quickly (search-as-you-type, tab switching) will intermittently show data for the previous selection — a genuine, user-visible bug, not just wasted network traffic.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated component uses useEffect, check for these before trusting it:
- Does the effect’s dependency array include everything it reads, or has a value been “left out” to stop a re-run — instead of using
useEffectEventor restructuring? - If the effect reads a value that changes often but shouldn’t cause a re-sync (like a callback or a theme), is it wrapped in
useEffectEventrather than silently omitted from deps? - Does a
fetch/subscription effect return a cleanup that aborts or unsubscribes — or does it leak on unmount and on every dependency change? - If the effect’s dependent value can change quickly (an id from a list, a search query), is there
AbortController-based cancellation, or can a stale response overwrite fresh state? - Is the effect doing something that belongs in an event handler (analytics, a POST) instead of reacting to a state flag?
🐛 Spot the bug
function Timer() {const [count, setCount] = useState(0);const [increment, setIncrement] = useState(1);
useEffect(() => { const id = setInterval(() => { setCount((c) => c + increment); // reads increment from the closure }, 1000); return () => clearInterval(id);}, []); // empty deps — the interval is only ever set up once
return ( <div> <output>{count}</output> <button onClick={() => setIncrement((i) => i + 1)}>bump increment</button> </div>);}Show the answer
The interval callback closes over increment from the render where the effect ran — with an empty dependency array, that effect runs exactly once, on mount, so the callback keeps referencing the initial increment (1) forever. Clicking “bump increment” changes the increment state and re-renders the component, but the already-running setInterval callback was created once and never gets recreated, so every tick still adds 1, no matter how many times the button is clicked. Proven in the probe: the fixed version (extracting the tick logic into useEffectEvent, as shown earlier in this lesson) reaches count === 3 after one tick at increment=1 and one at increment=2 — this buggy version would still show 2 (0 + 1 + 1) instead. The fix is useEffectEvent, not adding increment to the deps array (that would reconnect — recreate — the interval every time it changes, which is its own bug: the countdown restarts instead of ticking steadily).