102: Effects Deep Dive — Lifecycle, Dependencies, and Effect Events
Learning objectives
You will learn to:
- model Effects as synchronization processes;
- reason about setup and cleanup across rerenders;
- satisfy dependency rules without suppressing the linter;
- remove Effects that only transform React state;
- fix stale closures;
- use React 19.2
useEffectEventcorrectly; - separate events from synchronization;
- design subscriptions that survive Strict Mode;
- debug loops and duplicate work.
Effects synchronize external systems
An Effect is not “code after render.”
It exists to keep a React component synchronized with something outside React:
- network connection;
- browser event source;
- timer;
- media player;
- third-party widget;
- external store;
- imperative DOM API.
Example:
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
Think of one Effect as one independent synchronization process.
Lifecycle of one Effect
Suppose roomId changes from "general" to "support".
React performs:
render with support ↓ commit ↓ cleanup synchronization for general ↓ setup synchronization for support
When the component is removed:
cleanup current synchronization
In development Strict Mode, React performs an extra setup → cleanup → setup cycle to verify cleanup.
If that breaks your logic, the Effect is missing a correct inverse operation.
Do not disable Strict Mode to hide the problem.
Dependency arrays describe reactive inputs
useEffect(() => {
const connection =
createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, [serverUrl, roomId]);
Both serverUrl and roomId are reactive values read by the setup.
The dependencies are not a schedule you choose manually. They are part of the synchronization description.
Infinite loop example
useEffect(() => {
setOptions({
sort,
filter,
});
}, [sort, filter, options]);
This Effect sets a new object, causing another render. options changes, so the Effect runs again.
The real fix is usually to remove the duplicated state:
const options = {
sort,
filter,
};
Do not patch the loop with an empty dependency array.
You might not need an Effect
Derived data
Wrong:
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
Correct:
const fullName = `${firstName} ${lastName}`;
Event-specific logic
Wrong:
useEffect(() => {
if (submitted) {
saveTask(task);
}
}, [submitted, task]);
Correct:
async function handleSubmit() {
await saveTask(task);
}
The user action owns the operation.
Resetting state when identity changes
Sometimes a key is clearer than an Effect:
<Editor key={task.id} task={task} />
instead of:
useEffect(() => {
setDraft(task.title);
}, [task.id, task.title]);
Object and function dependencies
This Effect reruns on every render:
const options = {
serverUrl,
roomId,
};
useEffect(() => {
const connection = connect(options);
return () => connection.disconnect();
}, [options]);
because options is a new object each render.
Prefer creating it inside the Effect:
useEffect(() => {
const options = {
serverUrl,
roomId,
};
const connection = connect(options);
return () => connection.disconnect();
}, [serverUrl, roomId]);
Do not reach for useMemo solely to appease the Effect dependency rule unless memoization is the correct model.
Stale closures
Each render has its own snapshot.
useEffect(() => {
const id = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(id);
}, []);
This interval sees the count from the render that created it.
Adding count to dependencies resubscribes the timer:
useEffect(() => {
const id = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(id);
}, [count]);
Sometimes that is correct. Sometimes you need the latest value without recreating the external connection.
React 19.2: useEffectEvent
useEffectEvent separates non-reactive event-like logic from the synchronization itself.
import {
useEffect,
useEffectEvent,
} from 'react';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected', theme);
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => {
onConnected();
});
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
}
Changing theme should change the notification styling, but it should not disconnect and reconnect the room.
onConnected always sees the latest committed values.
Do not misuse Effect Events
Wrong mental model:
I do not like this dependency, so I will hide it in
useEffectEvent.
Effect Events are not an escape from reactivity. Use them for logic that is genuinely an event fired from an Effect.
Subscription example
function useOnlineStatus() {
const [online, setOnline] = useState(
navigator.onLine,
);
useEffect(() => {
function handleOnline() {
setOnline(true);
}
function handleOffline() {
setOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener(
'online',
handleOnline,
);
window.removeEventListener(
'offline',
handleOffline,
);
};
}, []);
return online;
}
Later you will see useSyncExternalStore, which is a stronger primitive for subscribing to external stores.
Fetching in Effects
Manual Effect fetching is valid for learning synchronization:
useEffect(() => {
const controller = new AbortController();
async function load() {
try {
const response = await fetch(
`/api/tasks?owner=${ownerId}`,
{ signal: controller.signal },
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
setTasks(data.tasks);
} catch (error) {
if (error.name !== 'AbortError') {
setError(error);
}
}
}
load();
return () => {
controller.abort();
};
}, [ownerId]);
But production server-state later moves to route loaders or TanStack Query v5 because those systems solve caching, deduplication, invalidation, retries, and ownership.
Effect decomposition
Avoid one giant Effect managing unrelated systems:
useEffect(() => {
connectChat();
document.title = title;
startAnalytics();
subscribeToResize();
return () => {
disconnectChat();
stopAnalytics();
unsubscribeResize();
};
}, [roomId, title, userId]);
Prefer separate Effects with separate dependency lifecycles.
Debugging Effect loops
When an Effect loops:
- identify which setter runs inside the Effect;
- identify which dependency that setter changes;
- ask whether the state can be derived;
- move event-specific work to the event;
- move non-reactive object/function creation inside the Effect;
- avoid suppressing
exhaustive-deps; - confirm cleanup fully reverses setup.
Exercises
- Remove an Effect that calculates filtered tasks.
- Fix an interval that logs stale state.
- Refactor a reconnecting chat Effect with
useEffectEvent. - Build a window event subscription with correct cleanup.
- Add abort behavior to an Effect-based request.
- Split one large Effect into independent synchronization processes.
Exit questions
- Why is an Effect not just “after render”?
- What determines Effect dependencies?
- Why does Strict Mode rerun setup/cleanup in development?
- What is a stale closure?
- What problem does
useEffectEventsolve? - When should logic move from an Effect to an event handler or render?
Official references
- https://react.dev/reference/react/useEffect
- https://react.dev/reference/react/useEffectEvent
- https://react.dev/learn/synchronizing-with-effects
- https://react.dev/learn/lifecycle-of-reactive-effects
- https://react.dev/learn/removing-effect-dependencies
- https://react.dev/learn/you-might-not-need-an-effect
Deep dive: dependency reasoning with real reactive values
The hardest part of Effects is not syntax. It is deciding which values are reactive and what the synchronization should depend on.
Consider:
function ChatRoom({ roomId, serverUrl }) {
const [theme, setTheme] = useState('dark');
useEffect(() => {
const connection = createConnection({
roomId,
serverUrl,
});
connection.connect();
connection.on('connected', () => {
showNotification('Connected', theme);
});
return () => {
connection.disconnect();
};
}, [roomId, serverUrl, theme]);
}
This reconnects whenever theme changes.
But the connection itself only depends on:
roomId serverUrl
The notification wants the latest theme, but the network subscription should not be recreated.
React 19.2 useEffectEvent expresses that split:
const onConnected = useEffectEvent(() => {
showNotification('Connected', theme);
});
useEffect(() => {
const connection = createConnection({
roomId,
serverUrl,
});
connection.on('connected', onConnected);
connection.connect();
return () => connection.disconnect();
}, [roomId, serverUrl]);
Important caveat
Do not pass Effect Events to children:
<Child onConnected={onConnected} />
Effect Events are local to the component's Effects. They are not general stable callbacks.
Do not include an Effect Event in a dependency array.
Reactive versus non-reactive values
Inside a component, props/state/variables derived from them are reactive:
const endpoint = `${baseUrl}/rooms/${roomId}`;
If the Effect reads endpoint, it indirectly depends on both baseUrl and roomId.
A module constant is not reactive:
const API_VERSION = 'v2';
A setter from useState has stable identity.
A ref object itself is stable, although .current is mutable and not reactive.
Understanding these categories makes dependency arrays predictable.
Removing a dependency by changing architecture
Suppose:
function Product({ productId, cart }) {
useEffect(() => {
analytics.viewedProduct(productId, cart.length);
}, [productId, cart]);
}
If the requirement is:
Record a product view when productId changes, using the cart count at that moment.
cart should not reschedule the Effect.
Use Effect Event:
const onViewedProduct = useEffectEvent((id) => {
analytics.viewedProduct(id, cart.length);
});
useEffect(() => {
onViewedProduct(productId);
}, [productId]);
This is not hiding a dependency. It models an event within the synchronization lifecycle.
Cleanup correctness and idempotence
An Effect should tolerate:
setup cleanup setup cleanup
If this breaks:
useEffect(() => {
globalRegistry.push(id);
return () => {
// forgot to remove
};
}, [id]);
Strict Mode exposes the leak.
Correct:
useEffect(() => {
globalRegistry.add(id);
return () => {
globalRegistry.delete(id);
};
}, [id]);
For third-party APIs, confirm destroy/unsubscribe methods can safely run even when partially initialized.
Event listener identity
This works because the same handleResize function is used for add/remove within one Effect execution:
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
This does not work:
window.addEventListener('resize', () => setWidth(window.innerWidth));
return () => {
window.removeEventListener('resize', () => setWidth(window.innerWidth));
};
Those are different function objects.
Subscription versus snapshot: when useSyncExternalStore is better
For a store that exists outside React, this pattern:
useEffect(() => {
return store.subscribe(() => {
setValue(store.getValue());
});
}, []);
can be fragile under concurrent rendering and server rendering.
React provides:
useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getServerSnapshot,
);
Use the dedicated primitive for external stores. Effects are still appropriate for many one-off browser subscriptions, but external-state libraries should integrate through the store protocol.
Data fetching and Suspense boundaries
Effect fetch:
useEffect(() => {
...
}, [id]);
starts after commit.
Router loader/query/framework data can start earlier.
Suspense-aware data sources can move pending UI to a boundary.
This impacts architecture:
Effect fetch → component renders loading → commit → request starts
versus:
route/query/framework → request may start before component commit → cache/dedupe/boundary logic
This is why production data architecture should not be "fetch everything in Effects."
Effect ordering
Different Effects in a component run in declaration order after commit, but designing correctness around incidental ordering between unrelated Effects is fragile.
Avoid:
useEffect(() => {
setReady(true);
}, []);
useEffect(() => {
if (ready) {
startSomething();
}
}, [ready]);
if this is one logical workflow.
Either perform the event/synchronization in one clear owner or model state transitions deliberately.
Async Effect callback trap
Do not write:
useEffect(async () => {
await load();
}, []);
An Effect callback may return a cleanup function, not a Promise.
Instead:
useEffect(() => {
let ignore = false;
async function load() {
const data = await getData();
if (!ignore) {
setData(data);
}
}
load();
return () => {
ignore = true;
};
}, []);
AbortController is preferred where the API supports cancellation because it can cancel work, not merely ignore the result.
Failure clinic: fake "mount" semantics
Legacy thinking:
useEffect(() => {
initializeFeature();
}, []);
asks "how do I run on mount?"
Modern reasoning asks:
What external system is this component synchronizing with for its lifetime?
If initialization belongs to the app/module rather than a component instance, move it out of the component lifecycle.
Debugging checklist
For any Effect, write:
External system: Reactive inputs: Setup: Cleanup: Why this cannot happen during render: Why this is not an event handler: Why a router/query/store is not the better owner:
If you cannot fill this in, the Effect probably needs redesign.
Exercises
- Fix a theme-triggered chat reconnection using
useEffectEvent. - Demonstrate Strict Mode revealing missing cleanup.
- Rewrite an anonymous event-listener cleanup bug.
- Convert an async Effect callback into a correct internal async function.
- Replace a hand-written external store subscription with
useSyncExternalStore. - Compare request start timing for Effect fetch versus route/query loading.
Mastery check
Explain:
- reactive values;
- Effect Events;
- cleanup symmetry;
- why external stores have a dedicated Hook;
- why async Effect callbacks cannot directly return Promises;
- how architecture can remove dependencies instead of suppressing them.
Production case study: WebSocket room with stable connection and current UI callbacks
Requirements:
connect when roomId changes disconnect when leaving show incoming message using latest muted/user settings do not reconnect when theme changes
Architecture:
function useRoom({ roomId, onMessage }) {
const handleMessage = useEffectEvent(onMessage);
useEffect(() => {
const socket = connectToRoom(roomId);
socket.on('message', (message) => {
handleMessage(message);
});
return () => {
socket.close();
};
}, [roomId]);
}
Consumer:
useRoom({
roomId,
onMessage(message) {
if (!muted) {
addToast({
message: message.text,
tone: theme === 'dark' ? 'light' : 'dark',
});
}
queryClient.setQueryData(
['room', roomId, 'messages'],
(current) => appendMessage(current, message),
);
},
});
The connection lifetime depends on roomId.
The event logic sees latest muted, theme, and query client state without reconnecting for those changes.
Realtime cache ownership
Do not copy messages from Query into component state just because WebSocket updates them.
If Query owns server messages, realtime events can update/invalidate that cache.
This is a key production pattern:
HTTP/query loads baseline WebSocket/SSE sends changes query cache remains server-state owner
The socket is transport, not a second source of truth.
Additional depth: Effect review checklist for code reviews
For every new Effect in a pull request, reviewers can ask:
- External system: what outside React is synchronized?
- Reactive inputs: which props/state determine synchronization?
- Cleanup: what undoes setup?
- Concurrency: can old async work finish after new work?
- Server rendering: what happens before Effects exist?
- Strict Mode: does setup/cleanup remain correct when replayed?
- Alternative owner: should Router, Query, CSS, event handler, or derived render own this instead?
- Dependency rule: are any linter warnings suppressed?
- Effect Event: is some logic event-like and non-reactive?
- Test: how will cleanup/race behavior be verified?
Example review
useEffect(() => {
setFiltered(tasks.filter((task) => task.title.includes(query)));
}, [tasks, query]);
Answers:
External system: none Cleanup: none Alternative owner: render
So remove it:
const filtered = tasks.filter((task) =>
task.title.includes(query),
);
Another review
useEffect(() => {
const subscription = analytics.subscribe(userId, handleEvent);
return () => subscription.unsubscribe();
}, [userId, handleEvent]);
Now there is an external system. The next question is whether handleEvent should resubscribe or become an Effect Event.
This review discipline prevents Effects from becoming a generic place for "logic that was hard to place."
