If you've been writing React for more than a few weeks, you've used useEffect. But do you really understand what happens when that hook runs? Most developers can guess "it runs after render" and know about cleanup functions—yet they still end up with infinite loops, race conditions, and mysterious state bugs. The gap between "knowing useEffect" and "understanding useEffect" is where real production problems hide.
After React 19, the mental model hasn't changed fundamentally, but the execution details matter more than ever. In this guide, we're going deeper than the surface level. We'll trace through React's fiber architecture, see exactly when cleanup functions execute, understand how dependency comparisons really work, and explore the edge cases that catch most developers off guard.
This isn't "how to use useEffect"—there are dozens of tutorials for that. This is about the mechanics underneath, the internals that determine whether your effects run one time or a thousand times, and why seemingly identical code behaves differently depending on how your components are structured.
Table of Contents
- What useEffect Really Does
- The Execution Timeline
- Dependency Array: How React Actually Tracks Changes
- Cleanup Functions and Fiber Updates
- The Race Condition Problem
- Memory Leaks and Reference Stability
- Practical Application: Real-World Scenarios
- FAQ
What useEffect Really Does
Before diving into execution details, let's clarify what useEffect actually is. It's not some magical function that runs "after render." It's a scheduler. React registers your effect as a side effect that needs to be executed during a specific phase of the component lifecycle.
When you write:
useEffect(() => {
console.log('Effect running');
}, [dependency]);
You're telling React: "After this component has been committed to the DOM, run this function. If the dependency changes on the next render, run it again. If I return a function, use that as cleanup."
The key word here is committed. React doesn't run effects during render. It can't. Rendering must be pure—no side effects allowed. Effects run after React has updated the DOM, during what's called the commit phase.
This separation matters because it means your effect runs on a component that's already been painted to the screen. That's why you can measure DOM elements in effects but not during render. It also explains why you can't "cancel" an effect mid-way through execution—once React commits, the effect is queued up and will run.
The Execution Timeline
Understanding when effects run relative to renders is crucial. Let's trace the actual sequence:
Render Phase (pure, no side effects)
- React calls your component function
- Component returns JSX
- React creates a fiber tree
- Dependencies are captured at this point
Paint to DOM (browser updates)
- React commits changes to the actual DOM
- Browser paints new pixels
- Layout and reflow happen
Effect Execution Phase (your code runs here)
- Effects are scheduled and executed
- Cleanup functions from previous render run first
- New effects run after cleanup
Here's what actually happens with multiple effects:
function Component() {
console.log('1. Render phase');
useEffect(() => {
console.log('3. Effect A (first in, first out)');
return () => console.log('4. Cleanup A');
}, []);
useEffect(() => {
console.log('5. Effect B');
return () => console.log('6. Cleanup B');
}, []);
return <div>Component</div>;
}
Output on first mount:
1. Render phase
2. DOM updated (browser paints)
3. Effect A (first in, first out)
5. Effect B
On unmount:
6. Cleanup B
4. Cleanup A
Notice: cleanup functions run in reverse order. This is intentional. If Effect A depends on something Effect B sets up, we need to clean up A first.
Dependency Array: How React Actually Tracks Changes
The dependency array is where most confusion lives. React doesn't do anything magical with it—no deep equality checks, no fancy algorithms. It does the simplest possible thing: Object.is() comparison.
When you write:
const [count, setCount] = useState(0);
const data = { id: 1 };
useEffect(() => {
console.log('Effect ran');
}, [count, data]);
React compares each element in this render's dependency array with the previous render's using Object.is():
// Pseudocode of what React actually does
const prevDeps = [0, { id: 1 }]; // previous render
const nextDeps = [0, { id: 1 }]; // current render
let hasChanged = false;
for (let i = 0; i < nextDeps.length; i++) {
if (!Object.is(prevDeps[i], nextDeps[i])) {
hasChanged = true;
break;
}
}
if (hasChanged || prevDeps === undefined) {
// Run effect
}
This means:
const obj1 = { id: 1 };
const obj2 = { id: 1 };
Object.is(obj1, obj2); // false! Different object references
Object.is(obj1, obj1); // true! Same reference
This is why this code creates an infinite loop:
function BadExample() {
useEffect(() => {
console.log('Running');
}, [{ id: 1 }]); // ❌ New object on every render!
}
// On each render:
// 1. New object { id: 1 } is created
// 2. Object.is(oldObj, newObj) is false
// 3. Effect runs
// 4. Component re-renders
// 5. Back to step 1
And why this works:
function GoodExample() {
const data = useMemo(() => ({ id: 1 }), []); // Stable reference
useEffect(() => {
console.log('Running once');
}, [data]);
}
// Object reference is stable, effect runs once
The Missing Piece: React doesn't validate your dependency array at runtime. If you omit a dependency, React won't know. If you include unnecessary dependencies, effects just run more often than needed. ESLint's exhaustive-deps rule exists because React itself can't catch this mistake.
Cleanup Functions and Fiber Updates
Cleanup functions are where the fiber architecture becomes visible. Each component instance gets a fiber node that tracks:
- The component's state
- Effects associated with this component
- Cleanup functions that need to run
- The current and previous version of dependencies
When React detects a dependency change (or a component unmounts), it doesn't just call your cleanup function randomly. Here's the actual sequence:
interface FiberNode {
state: any;
hooks: Hook[];
updateQueue: Effect[];
}
interface Effect {
tag: 'Insertion' | 'Mutation' | 'Layout';
create: () => (() => void) | void;
destroy: (() => void) | null;
deps: any[];
next: Effect | null;
}
When your component updates:
- Cleanup Scheduling: React iterates through the component's effect list
- Dependency Check: For each effect, React compares the current dependency array with the previous one
- Cleanup Execution: If dependencies changed, the cleanup function (
destroy) from the previous render is marked for execution - New Effect Scheduled: The new effect (created from the new render) is queued
The critical detail: cleanup functions and new effects are separate operations. React schedules all cleanups to run in one batch, then all new effects in another batch.
function DataFetcher({ userId }) {
useEffect(() => {
const controller = new AbortController();
fetch(`/user/${userId}`, { signal: controller.signal })
.then(r => r.json())
.then(data => console.log('Data:', data));
return () => {
console.log('Cleanup: Aborting fetch for user', userId);
controller.abort();
};
}, [userId]);
return <div>User data</div>;
}
// When userId changes from 1 to 2:
// 1. Component renders with userId=2
// 2. React marks cleanup (from userId=1 effect) for execution
// 3. React marks new effect (with userId=2) for execution
// 4. Cleanup runs: aborts userId=1 fetch
// 5. New effect runs: starts userId=2 fetch
This ordering is not accidental. It ensures that old effects are cleaned up before new ones initialize, preventing resource conflicts.
The Race Condition Problem
Here's where understanding the timeline becomes a production issue. Consider this common pattern:
const [data, setData] = useState(null);
useEffect(() => {
const fetchData = async () => {
const response = await fetch(`/api/data/${id}`);
const json = await response.json();
setData(json); // Dangerous!
};
fetchData();
}, [id]);
On the surface, this looks correct. When id changes, fetch new data. But what if:
- User loads component with
id=1 - Effect starts fetch for
/api/data/1 - Before response arrives, user navigates away
- Component unmounts, then remounts with
id=2 - Cleanup doesn't cancel the original fetch
/api/data/1response arrives after/api/data/2- State updates with old data
React's fiber architecture doesn't protect you here. The cleanup function runs, but it doesn't automatically cancel the fetch that was already in flight.
The solution demonstrates why understanding the execution model matters:
const [data, setData] = useState(null);
useEffect(() => {
let isMounted = true; // Flag to track component lifecycle
const fetchData = async () => {
const response = await fetch(`/api/data/${id}`);
const json = await response.json();
if (isMounted) {
setData(json);
}
};
fetchData();
return () => {
isMounted = false; // Prevent state updates after unmount
};
}, [id]);
Or, better yet, use AbortController:
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
try {
const response = await fetch(`/api/data/${id}`, {
signal: controller.signal
});
const json = await response.json();
setData(json);
} catch (e) {
if (e.name !== 'AbortError') {
console.error(e);
}
}
};
fetchData();
return () => controller.abort();
}, [id]);
The second approach leverages the browser's native cancellation mechanism. When the cleanup function runs, it aborts the fetch immediately, preventing the response from updating state.
Memory Leaks and Reference Stability
Memory leaks in React components almost always trace back to effects that set up listeners without proper cleanup. But the real issue is usually reference stability.
function ChatComponent({ userId }) {
const messageHandler = (message) => {
console.log(`${userId}: ${message}`);
};
useEffect(() => {
socket.on('message', messageHandler);
return () => {
socket.off('message', messageHandler);
};
}, [userId]); // ❌ Passing userId without messageHandler
}
This creates a subtle leak: messageHandler references userId, but messageHandler is not in the dependency array. When userId changes:
- New
messageHandlerfunction is created on each render Object.is(oldMessageHandler, newMessageHandler)is always false- Effect runs, registering a new listener with the new function
- Cleanup tries to unregister the old listener... but using the new function reference
- The old listener remains registered
The socket now has multiple message handlers stacked up, all processing messages.
The fix is to include functions in the dependency array or use useCallback:
const messageHandler = useCallback((message) => {
console.log(`${userId}: ${message}`);
}, [userId]);
useEffect(() => {
socket.on('message', messageHandler);
return () => {
socket.off('message', messageHandler);
};
}, [messageHandler]); // ✅ Now messageHandler is stable unless userId changes
Or use the function reference approach:
useEffect(() => {
const handler = (message) => {
console.log(`${userId}: ${message}`);
};
socket.on('message', handler);
return () => {
socket.off('message', handler);
};
}, [userId]); // ✅ Same reference is cleaned up
The fiber architecture doesn't check reference types for you. It compares using Object.is() and moves on. If your cleanup function can't find what to clean up because the reference changed, the cleanup silently fails.
Practical Application: Real-World Scenarios
Scenario: Building a Resilient Data Fetching Hook
Let's build a production-grade data fetching pattern that handles the complexities we've discussed:
interface UseFetchOptions<T> {
onError?: (error: Error) => void;
onSuccess?: (data: T) => void;
refetchInterval?: number;
}
interface UseFetchState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
function useFetch<T>(
url: string,
options?: UseFetchOptions<T>
): UseFetchState<T> {
const [state, setState] = useState<UseFetchState<T>>({
data: null,
loading: true,
error: null,
});
useEffect(() => {
let isMounted = true;
let timeout: NodeJS.Timeout;
const performFetch = async () => {
try {
// Clear previous error on new fetch
setState(prev => ({ ...prev, loading: true, error: null }));
const controller = new AbortController();
const response = await fetch(url, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json() as T;
// Only update state if component is still mounted
if (isMounted) {
setState({ data, loading: false, error: null });
options?.onSuccess?.(data);
}
} catch (error) {
if (isMounted && error instanceof Error) {
if (error.name !== 'AbortError') {
setState(prev => ({
...prev,
error,
loading: false,
}));
options?.onError?.(error);
}
}
}
};
performFetch();
// Optional: refetch at intervals
if (options?.refetchInterval) {
timeout = setInterval(performFetch, options.refetchInterval);
}
return () => {
isMounted = false;
if (timeout) clearInterval(timeout);
};
}, [url, options?.refetchInterval]);
return state;
}
// Usage
function UserProfile({ userId }: { userId: string }) {
const { data, loading, error } = useFetch<{ name: string; email: string }>(
`/api/users/${userId}`,
{
onSuccess: (data) => {
console.log('User loaded:', data.name);
},
onError: (error) => {
console.error('Failed to load user:', error.message);
},
}
);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h1>{data?.name}</h1>
<p>{data?.email}</p>
</div>
);
}
JavaScript Version:
function useFetch(url, options) {
const [state, setState] = useState({
data: null,
loading: true,
error: null,
});
useEffect(() => {
let isMounted = true;
let timeout;
const performFetch = async () => {
try {
setState(prev => ({ ...prev, loading: true, error: null }));
const controller = new AbortController();
const response = await fetch(url, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (isMounted) {
setState({ data, loading: false, error: null });
options?.onSuccess?.(data);
}
} catch (error) {
if (isMounted && error?.name !== 'AbortError') {
setState(prev => ({
...prev,
error,
loading: false,
}));
options?.onError?.(error);
}
}
};
performFetch();
if (options?.refetchInterval) {
timeout = setInterval(performFetch, options.refetchInterval);
}
return () => {
isMounted = false;
if (timeout) clearInterval(timeout);
};
}, [url, options?.refetchInterval]);
return state;
}
Scenario: Building a Form with Validation and Auto-Save
This example demonstrates how understanding effect cleanup timing prevents data loss:
interface FormData {
title: string;
content: string;
}
interface UseAutoSaveOptions {
delay?: number;
onSave?: (data: FormData) => Promise<void>;
}
function useAutoSave(
data: FormData,
options: UseAutoSaveOptions = {}
) {
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'error'>('idle');
const delayMs = options.delay ?? 1000;
useEffect(() => {
let timeoutId: NodeJS.Timeout;
const save = async () => {
try {
setSaveStatus('saving');
await options.onSave?.(data);
setSaveStatus('idle');
} catch (error) {
setSaveStatus('error');
console.error('Auto-save failed:', error);
}
};
// Debounce: only save if user hasn't typed for `delay` ms
timeoutId = setTimeout(save, delayMs);
// Cleanup: cancel pending save if data changes again
return () => clearTimeout(timeoutId);
}, [data, delayMs, options]);
return saveStatus;
}
function EditDocument() {
const [form, setForm] = useState<FormData>({ title: '', content: '' });
const saveStatus = useAutoSave(form, {
delay: 1500,
onSave: async (data) => {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 500));
console.log('Saved:', data);
},
});
return (
<div>
<input
value={form.title}
onChange={(e) => setForm(prev => ({ ...prev, title: e.target.value }))}
placeholder="Title"
/>
<textarea
value={form.content}
onChange={(e) => setForm(prev => ({ ...prev, content: e.target.value }))}
placeholder="Content"
/>
<div>
{saveStatus === 'saving' && <span>Saving...</span>}
{saveStatus === 'error' && <span style={{ color: 'red' }}>Save failed</span>}
</div>
</div>
);
}
JavaScript Version:
function useAutoSave(data, options = {}) {
const [saveStatus, setSaveStatus] = useState('idle');
const delayMs = options.delay ?? 1000;
useEffect(() => {
let timeoutId;
const save = async () => {
try {
setSaveStatus('saving');
await options.onSave?.(data);
setSaveStatus('idle');
} catch (error) {
setSaveStatus('error');
console.error('Auto-save failed:', error);
}
};
timeoutId = setTimeout(save, delayMs);
return () => clearTimeout(timeoutId);
}, [data, delayMs, options]);
return saveStatus;
}
Performance Note: This pattern prevents unnecessary saves by leveraging cleanup function timing. Each keystroke clears the pending save (cleanup runs), and a new save is scheduled. Only after the user stops typing for 1.5 seconds does the actual save execute.
FAQ
Q: Why does my effect run twice on mount in development?
A: React 18+ intentionally double-invokes effects in development when StrictMode is enabled. This helps catch bugs where effects aren't idempotent. Each component mounts, then immediately unmounts and remounts. It's a feature, not a bug. Your cleanup function should handle being called multiple times.
useEffect(() => {
console.log('Mounted');
return () => console.log('Cleanup called'); // Will be called even on mount in dev
}, []);
Disable StrictMode in production, but leave it in development.
Q: Can I make effects run on every render without a dependency array?
A: Yes, but you probably shouldn't:
useEffect(() => {
console.log('Runs every render');
}); // No dependency array
This runs after every render, including when props or state change. It's useful for measuring DOM or syncing external state, but it's a performance risk if you're not careful. It's almost never the right answer.
Q: What's the difference between Object.is() and === for dependency tracking?
A: For primitives, they're identical. For objects and functions, Object.is() strictly compares references:
Object.is(5, 5) === (5 === 5) // true
Object.is({}, {}) === ({} === {}) // true (both false)
Object.is(NaN, NaN) // true (different from ===)
Object.is(0, -0) // false (different from ===)
For useEffect dependencies, you'll mainly hit the object/function differences.
Q: Can I update state inside cleanup functions?
A: Technically yes, but it's a red flag:
useEffect(() => {
return () => {
setState(value); // ❌ Cleanup is called after unmount
};
}, []);
When cleanup runs during unmount, the component is no longer mounted. Setting state is ignored, and React will warn you. If the effect runs before unmount (dependency change), the state update happens, but it's confusing because cleanup usually means "stop doing something," not "do something else."
Q: Does useEffect work with custom hooks?
A: Yes, hooks can call hooks:
function useCustomEffect() {
useEffect(() => {
console.log('Inside custom hook');
}, []);
}
As long as you follow the Rules of Hooks (no conditional calls, only call at top level), it works. The effect still belongs to the component that calls the custom hook.
Q: What happens if I use async directly in useEffect?
A: You can't:
useEffect(async () => { // ❌ TypeScript error
const data = await fetch('/data');
}, []);
useEffect's callback must return either void or a cleanup function. An async function returns a Promise. Instead, create an async function inside:
useEffect(() => {
const fetchData = async () => {
const data = await fetch('/data');
};
fetchData();
}, []);
This is a safety feature—it forces you to handle errors and cleanup explicitly.
Related Articles
- React State Management: useState vs useReducer
- Mastering useCallback and useMemo
- Understanding React's Fiber Architecture
- Performance: Avoiding Unnecessary Re-renders
Questions? The comments section below is the best place to discuss edge cases, gotchas, and production patterns. What confuses you most about useEffect?
Google AdSense Placeholder
CONTENT SLOT