"useEffect is NOT a lifecycle hook."
If you think useEffect(fn, []) is componentDidMount, you've already lost.
This "Lifecycle Mental Model" (Mount, Update, Unmount) is a carryover from Class Components. It is actively harmful in Hooks. It causes stale closures, infinite loops, and data inconsistencies.
In Class Components, you wrote code based on Time ("Do this when it mounts").
In Hooks, you write code based on State ("Do this when data changes").
02. The Synchronization Mental Model
Old Thinking
"I want to run this log only once."
Correct Thinking
"I want `console.log` to be synchronized with `text`. If `text` changes, log it."
03. Dependency Integrity
The Golden Rule
"You do not choose your dependencies. Your code chooses them."
If a variable is defined inside the component and used inside the effect, it MUST be in the dependency array. No exceptions.
// โ The "Lying" Pattern
useEffect(() => {
const next = count + step; // Uses 'count' and 'step'
console.log(next);
}, []); // โ BUG: 'next' will forever be initial value. Stale Closure.
// โ
The Truthful Pattern
useEffect(() => {
const next = count + step;
console.log(next);
}, [count, step]); // โ
Re-runs whenever ingredients change.
Deep Dive: Object Referential Equality
React compares dependencies using `Object.is()`. If you pass an object or array literal `[]` as a dependency, it is a *new* object every render.
This causes the Effect to run infinitely.
Fix: Wrap objects in `useMemo` or primitives.
04. Race Conditions & AbortController
Imagine a user clicks "User 1", then quickly "User 2". The network request for User 1 might finish after User 2. If you don't handle this, your UI will show "User 2" selected but "User 1" data. This is a Race Condition.
useEffect(() => {
let ignore = false;
async function fetchData() {
const res = await fetch(url);
const data = await res.json();
// ๐ก๏ธ Safety Check
if (!ignore) {
setData(data);
}
}
fetchData();
// ๐งน Cleanup runs first when url changes
return () => { ignore = true; };
}, [url]);
The AbortController Pattern (Professional)
Using a boolean flag is okay, but `AbortController` actually cancels the network request, saving bandwidth.
Deep Dive: AbortSignal
Modern fetch() accepts a signal option.
const controller = new AbortController();
fetch(url, { signal: controller.signal });
In the useEffect cleanup function, calling controller.abort() automatically rejects the promise with an "AbortError", which you can catch and ignore.
05. The "Fetch-Then-Render" Anti-Pattern
โ ๏ธ Don't fetch in useEffect for critical data
Fetching in `useEffect` causes a "Waterfall".
1. Download JS Bundle -> 2. Render App -> 3. Execute Effect -> 4. Start Fetch.
Better: Use a library like TanStack Query or SWR.
Best (In 2026): Use React Server Components (RSC) to fetch on the server.
06. Effects vs Events: The Decision Tree
Ask yourself: "Who triggered this?"
Event Handler
Did the user click, type, or submit? Put the logic in `onClick`, `onSubmit`. Do NOT use `useEffect`.
useEffect
Did the user just arrive at a page? Did a prop change that requires a 3rd party library to re-sync? Use `useEffect`.
07. Refactoring to Custom Hooks
If you see a `useEffect` in your main component, smell code. Abstracting effects into custom hooks makes your component declarative ("I want to sync window size") instead of implementation detail ("Add event listener...").
// โ
useWindowListener.js
export function useWindowListener(eventType, listener) {
useEffect(() => {
window.addEventListener(eventType, listener);
return () => window.removeEventListener(eventType, listener);
}, [eventType, listener]);
}
// Component.js
function App() {
// So clean! No useEffect visible.
useWindowListener('resize', handleResize);
return ...
}
08. The Sync Visualizer
Below is a tool to visualize the Setup and Cleanup cycle of useEffect. Change the connection speed to see how React cleans up the *previous* effect before setting up the *new* one.