useEffect Dependency Array: Deep Mechanics Explained
The dependency array in useEffect is one of React's most misunderstood features. Developers constantly run into bugs where effects fire at the wrong time, or worse, create infinite loops that crash their apps. The frustration usually stems from not understanding why the array exists or what React actually does with it.
Here's the thing: the dependency array isn't just "here's when to run this"—it's a communication channel between you and React's scheduler. When you pass an array, you're making a promise to React about which values the effect depends on. If you break that promise (by omitting a dependency or including one incorrectly), React can't help you track what changed. The effect either runs too often, too rarely, or not at all when it should.
In this guide, we'll dig into the actual mechanics of how React evaluates dependencies, examine the three exceptions everyone gets wrong, and show you patterns that prevent 90% of useEffect bugs in production code.
Table of Contents
- The Three Dependency Array Modes
- How React Tracks Dependencies
- The Three Exceptions That Trip Everyone Up
- Common Pitfalls and How to Avoid Them
- Practical Application: Data Fetching Pattern
- FAQ
The Three Dependency Array Modes
The dependency array fundamentally changes the execution behavior of useEffect. There are exactly three ways to use it, and each has distinct implications:
No Dependency Array (Runs Every Render)
useEffect(() => {
// This runs after EVERY component render
console.log('Runs on every render');
});
useEffect(() => {
// This runs after EVERY component render
console.log('Runs on every render');
});
When you omit the dependency array entirely, React executes the effect function after every component render. This is rarely what you want because it easily creates performance problems. If your effect triggers a state update (like setCount), you'll create an infinite loop: render → effect runs → state updates → render → effect runs again.
This pattern is almost never intentional, and linters will warn you if you forget the array.
Empty Dependency Array (Runs Once on Mount)
useEffect(() => {
// This runs exactly ONCE, right after initial render
console.log('Runs only once');
}, []);
useEffect(() => {
// This runs exactly ONCE, right after initial render
console.log('Runs only once');
}, []);
An empty array tells React: "This effect has zero dependencies. Don't watch anything. Just run once when the component first mounts." This is perfect for initialization logic like fetching data for the first time, subscribing to a socket connection, or starting a timer.
The key insight: this is different from omitting the array. You're explicitly telling React that there's nothing to watch, so it should never re-run.
Populated Dependency Array (Runs When Dependencies Change)
useEffect(() => {
// This runs whenever 'count' or 'userId' changes
console.log(`Count is ${count}, User is ${userId}`);
}, [count, userId]);
useEffect(() => {
// This runs whenever 'count' or 'userId' changes
console.log(`Count is ${count}, User is ${userId}`);
}, [count, userId]);
When you provide values in the array, React watches those specific values. If any of them change between renders, the effect re-runs. The comparison is done using Object.is() (which is like strict equality ===, but treats NaN as equal to NaN).
This is the most common pattern and the one that causes the most confusion because the rules for what to include aren't always obvious.
How React Tracks Dependencies
React uses a surprisingly simple mechanism to track dependencies. After each render, React compares the new dependency array with the previous one, element by element, using strict equality.
Let's trace through an example:
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(data => setUser(data));
}, [userId]); // ← Dependency array
return <div>{user?.name}</div>;
}
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(data => setUser(data));
}, [userId]); // ← Dependency array
return <div>{user?.name}</div>;
}
Here's what happens internally:
- First render:
userId = 5. React stores[5]as the previous dependencies. - Effect runs:
fetchUser(5)executes, user data loads. - Second render:
userId = 5. React compares[5](current) with[5](previous). They're identical, so effect doesn't run. - Third render:
userId = 10. React compares[10](current) with[5](previous). They differ, so effect runs again.
This is where objects and functions trip people up. Unlike primitives, objects are compared by reference:
function ItemList() {
const [items, setItems] = useState([]);
// ❌ PROBLEM: Object is recreated every render
const config = { apiUrl: 'https://api.example.com' };
useEffect(() => {
fetchItems(config).then(data => setItems(data));
}, [config]); // Re-runs every render, not just when apiUrl changes!
}
function ItemList() {
const [items, setItems] = useState([]);
// ❌ PROBLEM: Object is recreated every render
const config = { apiUrl: 'https://api.example.com' };
useEffect(() => {
fetchItems(config).then(data => setItems(data));
}, [config]); // Re-runs every render, not just when apiUrl changes!
}
Even though config has the same values every time, it's a different object, so Object.is(config, prevConfig) returns false. The effect re-runs unnecessarily.
The fix is either to move the object outside the component or use destructuring to extract the specific value you care about:
function ItemList() {
const [items, setItems] = useState([]);
// ✅ BETTER: Extract the specific string value
const apiUrl = 'https://api.example.com';
useEffect(() => {
fetchItems(apiUrl).then(data => setItems(data));
}, [apiUrl]);
}
function ItemList() {
const [items, setItems] = useState([]);
// ✅ BETTER: Extract the specific string value
const apiUrl = 'https://api.example.com';
useEffect(() => {
fetchItems(apiUrl).then(data => setItems(data));
}, [apiUrl]);
}
The Three Exceptions That Trip Everyone Up
Here's where most developers get confused. The rule is: "add everything you use in the effect to the dependency array." But there are three explicit exceptions where you should not add something:
Exception 1: Values Defined Inside the Effect
If a variable is created and used only inside the effect function, don't add it as a dependency:
useEffect(() => {
// ✅ CORRECT: fetchedData is only used here, not defined outside
const response = fetch('/api/users');
// Don't add 'response' to dependencies
}, []);
useEffect(() => {
// ✅ CORRECT: fetchedData is only used here, not defined outside
const response = fetch('/api/users');
// Don't add 'response' to dependencies
}, []);
Why? Because it's a local variable created fresh each time the effect runs. It can't be "compared" across renders because it didn't exist in the previous render.
Exception 2: Functions and Values from Outside the Component
External APIs, utilities, or constants defined outside your component function don't need to be dependencies:
// Defined OUTSIDE component
async function fetchUser(id) {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
function UserCard({ userId }: { userId: number }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
// ❌ DON'T add 'fetchUser' as dependency—it's external
// ✅ DO add 'userId'—it's a prop that can change
}, [userId]);
return <div>{user?.name}</div>;
}
// Defined OUTSIDE component
async function fetchUser(id) {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
function UserCard({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
// ❌ DON'T add 'fetchUser' as dependency—it's external
// ✅ DO add 'userId'—it's a prop that can change
}, [userId]);
return <div>{user?.name}</div>;
}
The function never changes (it's defined once at the module level), so watching it is pointless. React knows this, and linters know this too.
Exception 3: State Setter Functions from useState
The setter function returned by useState is guaranteed by React to never change:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1); // ← Always the same function
}, 1000);
return () => clearInterval(timer);
// ❌ DON'T add 'setCount' as dependency
}, []); // Empty array is fine
}
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1); // ← Always the same function
}, 1000);
return () => clearInterval(timer);
// ❌ DON'T add 'setCount' as dependency
}, []); // Empty array is fine
}
React deliberately ensures that the function identity never changes across renders, specifically to prevent unnecessary effect re-runs. This is an optimization that React handles for you.
Common Pitfalls and How to Avoid Them
Pitfall 1: Function Dependencies Causing Loops
When you define a function inside the component and use it in an effect, React creates a new function object every render:
function SearchResults() {
const [results, setResults] = useState([]);
function performSearch(query: string) {
return fetch(`/api/search?q=${query}`).then(r => r.json());
}
useEffect(() => {
performSearch('react');
// ❌ PROBLEM: performSearch is recreated every render
// If you add it as dependency, effect runs every render
}, [performSearch]); // ← Infinite loop!
}
function SearchResults() {
const [results, setResults] = useState([]);
function performSearch(query) {
return fetch(`/api/search?q=${query}`).then(r => r.json());
}
useEffect(() => {
performSearch('react');
// ❌ PROBLEM: performSearch is recreated every render
// If you add it as dependency, effect runs every render
}, [performSearch]); // ← Infinite loop!
}
The fix is to either move the function outside the component or wrap it with useCallback:
import { useCallback, useEffect, useState } from 'react';
function SearchResults() {
const [results, setResults] = useState([]);
// ✅ useCallback memoizes the function
const performSearch = useCallback((query: string) => {
return fetch(`/api/search?q=${query}`).then(r => r.json());
}, []); // No dependencies means function never changes
useEffect(() => {
performSearch('react').then(setResults);
}, [performSearch]); // Now safe to use as dependency
}
import { useCallback, useEffect, useState } from 'react';
function SearchResults() {
const [results, setResults] = useState([]);
// ✅ useCallback memoizes the function
const performSearch = useCallback((query) => {
return fetch(`/api/search?q=${query}`).then(r => r.json());
}, []); // No dependencies means function never changes
useEffect(() => {
performSearch('react').then(setResults);
}, [performSearch]); // Now safe to use as dependency
}
Pitfall 2: Stale Closures
An effect captures values from the component when it runs. If you omit a dependency that changes, the effect uses the old value:
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
console.log(`Count is ${count}`); // ← Always logs 0!
}, 1000);
return () => clearInterval(interval);
}, []); // ❌ Missing 'count' dependency
}
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
console.log(`Count is ${count}`); // ← Always logs 0!
}, 1000);
return () => clearInterval(interval);
}, []); // ❌ Missing 'count' dependency
}
When count changes, the effect doesn't re-run, so the interval is never recreated. It still references the original count = 0 from the closure.
The fix is to add the missing dependency:
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
console.log(`Count is ${count}`); // ✅ Logs current value
}, 1000);
return () => clearInterval(interval);
}, [count]); // ✅ Include 'count'
}
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
console.log(`Count is ${count}`); // ✅ Logs current value
}, 1000);
return () => clearInterval(interval);
}, [count]); // ✅ Include 'count'
}
Now the effect re-runs when count changes, and the interval is recreated with the new value.
Practical Application: Data Fetching Pattern
Here's a real-world pattern used by teams at ByteDance and Alibaba: fetching data when props change while handling race conditions:
import { useEffect, useState } from 'react';
interface User {
id: number;
name: string;
email: string;
}
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Flag to prevent state updates from stale requests
let isMounted = true;
async function loadUser() {
try {
setLoading(true);
setError(null);
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
const data = await response.json();
// Only update state if component is still mounted
if (isMounted) {
setUser(data);
}
} catch (err) {
if (isMounted) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
} finally {
if (isMounted) {
setLoading(false);
}
}
}
loadUser();
// Cleanup: mark as unmounted
return () => {
isMounted = false;
};
}, [userId]); // Re-run when userId changes
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return <div>No user found</div>;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
export default UserProfile;
import { useEffect, useState } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// Flag to prevent state updates from stale requests
let isMounted = true;
async function loadUser() {
try {
setLoading(true);
setError(null);
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
const data = await response.json();
// Only update state if component is still mounted
if (isMounted) {
setUser(data);
}
} catch (err) {
if (isMounted) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
} finally {
if (isMounted) {
setLoading(false);
}
}
}
loadUser();
// Cleanup: mark as unmounted
return () => {
isMounted = false;
};
}, [userId]); // Re-run when userId changes
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return <div>No user found</div>;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
export default UserProfile;
This pattern handles several critical scenarios:
- Dependency tracking: The effect re-runs when
userIdchanges, fetching the new user's data. - Race conditions: If
userIdchanges while a fetch is in flight, theisMountedflag prevents stale data from being applied. - Memory leaks: The cleanup function prevents state updates on unmounted components.
- No unnecessary dependencies: We only watch
userId, notsetUser,setLoading, orsetError.
FAQ
Q: Should I add useCallback to everything to prevent dependency changes?
A: No. useCallback adds overhead and makes code harder to read. Use it only when:
- A function is a dependency in another effect or memo
- You're optimizing performance and have measured the improvement
- A parent component passes the function to memoized children
In most cases, letting functions be recreated is fine. React's linter (ESLint) will tell you when you actually need it.
Q: What if my effect depends on an object from props?
A: Extract the specific value you care about:
// ❌ Avoid
useEffect(() => {
console.log(config.apiUrl);
}, [config]); // Re-runs every render
// ✅ Better
const apiUrl = config.apiUrl;
useEffect(() => {
console.log(apiUrl);
}, [apiUrl]); // Only re-runs if apiUrl actually changes
Q: Can I have multiple dependency arrays in one component?
A: Yes, but each useEffect call has its own array. This is actually a common pattern—separate effects for separate concerns:
useEffect(() => {
// Effect 1: Fetch user data
}, [userId]);
useEffect(() => {
// Effect 2: Subscribe to notifications
}, []);
useEffect(() => {
// Effect 3: Save preferences
}, [preferences]);
Q: Is the dependency array order important?
A: No, the order doesn't matter. React compares values, not positions. [a, b] and [b, a] are treated the same.
Q: How do I know if I'm missing a dependency?
A: Install and run the ESLint plugin eslint-plugin-react-hooks. It automatically detects missing dependencies and warns you. This is the single best tool for preventing dependency-related bugs.
Related Articles:
- useState Fundamentals: State, Closures, and Batching
- React Fiber Architecture: How Reconciliation Works
- useCallback and Performance Optimization
Questions? Share your trickiest dependency array bug in the comments below—we learn best from real-world examples.
Google AdSense Placeholder
CONTENT SLOT