The dependency array is the most powerful and most frequently misused part of useEffect. It tells React which values the effect depends on — and React uses that information to decide when to re-run the effect. Getting the dependency array right is critical: too few dependencies produces stale data and hard-to-debug bugs; too many produces unnecessary re-runs; an empty array when dependencies are needed causes subtle logic errors that only surface in specific user flows. In this lesson you will master every aspect of the dependency array and build the habit of listing dependencies correctly the first time.
The Three Forms Revisited
// Form 1: No dependency array — effect runs after EVERY render
// Use case: extremely rare — debugging, measuring renders
useEffect(() => {
console.log('PostCard rendered', Date.now());
});
// Form 2: Empty array — effect runs ONCE after mount
// Use case: fetch initial data, set up global listeners, one-time operations
useEffect(() => {
fetchInitialPosts();
}, []);
// Form 3: Array with dependencies — effect runs after mount AND when any dep changes
// Use case: refetch when a filter/ID changes, sync state to storage
useEffect(() => {
fetchPosts({ tag: selectedTag, page: currentPage });
}, [selectedTag, currentPage]); // re-runs whenever either changes
===). Primitives (strings, numbers, booleans) compare by value: 'mern' === 'mern' is true so the effect does not re-run. Objects and arrays compare by reference: even if two arrays contain the same items, [] !== [], so a new array object on every render would cause the effect to re-run on every render. Avoid passing object or array literals directly in the dependency array.eslint-plugin-react-hooks package and enable the exhaustive-deps rule — it automatically warns you when you are missing a dependency. Vite projects created with npm create vite@latest include this rule by default. Never silence the rule with a comment unless you have a specific, deliberate reason — it is almost always right.useCallback, or restructure the code to avoid the dependency.What Happens Step by Step
useEffect(() => { fetchPosts(page); }, [page]);
Initial render (page = 1):
→ React renders the component
→ React commits the DOM changes
→ useEffect runs: fetchPosts(1)
User changes page to 2:
→ React re-renders (page = 2)
→ React commits DOM changes
→ React runs the cleanup from the previous effect (if any)
→ useEffect runs again: fetchPosts(2)
User changes page to 2 AGAIN (same value):
→ React re-renders (page = 2)
→ React compares: Object.is(2, 2) === true → NO change
→ useEffect does NOT run again
Missing Dependencies — Stale Closure Bug
// ❌ Wrong: postId is used inside the effect but not listed as a dependency
function PostPage({ postId }) {
const [post, setPost] = useState(null);
useEffect(() => {
fetchPost(postId).then(setPost); // postId captured from the first render
// If postId changes (user navigates to a different post),
// this effect does NOT re-run — it still shows the old post's data!
}, []); // ← missing postId
}
// ✅ Correct: list postId as a dependency
useEffect(() => {
fetchPost(postId).then(setPost);
}, [postId]); // re-runs whenever postId changes — always fresh data
Object and Array Dependencies — The Reference Problem
// ❌ Wrong: object literal creates a new reference on every render
function PostList({ filters }) {
const options = { tag: filters.tag, page: filters.page }; // new object every render!
useEffect(() => {
fetchPosts(options);
}, [options]); // options !== options (new reference) → effect runs every render
}
// ✅ Fix 1: Use primitive values from the object as dependencies
useEffect(() => {
fetchPosts({ tag: filters.tag, page: filters.page });
}, [filters.tag, filters.page]); // primitives compare by value ✓
// ✅ Fix 2: Move the object creation inside the effect
useEffect(() => {
const options = { tag: filters.tag, page: filters.page }; // created inside — no stale closure
fetchPosts(options);
}, [filters.tag, filters.page]); // ✓
// ✅ Fix 3: Memoize the object with useMemo so it only changes when values change
const options = useMemo(
() => ({ tag: filters.tag, page: filters.page }),
[filters.tag, filters.page]
);
Function Dependencies — the useCallback Solution
// ❌ Wrong: function re-created every render → effect re-runs every render
function PostList({ onLoadComplete }) {
useEffect(() => {
fetchPosts().then(posts => {
setPosts(posts);
onLoadComplete(posts.length); // uses onLoadComplete
});
}, [onLoadComplete]); // onLoadComplete is a new function every render!
}
// ✅ Fix: wrap the function in useCallback in the parent
// Parent component:
const handleLoadComplete = useCallback((count) => {
setPostCount(count);
}, []); // stable reference — only created once
// Now onLoadComplete has a stable reference — effect only runs once
<PostList onLoadComplete={handleLoadComplete} />
The Exhaustive Deps Rule in Practice
function BlogPage() {
const [posts, setPosts] = useState([]);
const [tag, setTag] = useState('');
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
useEffect(() => {
// Everything used inside this effect must be in the dependency array
const fetchData = async () => {
setLoading(true);
try {
const res = await axios.get('/api/posts', { params: { tag, page, limit: 10 } });
setPosts(res.data.data);
} finally {
setLoading(false);
}
};
fetchData();
}, [tag, page]);
// ✓ tag and page are in deps — re-fetches when filter or page changes
// ✓ setPosts and setLoading are stable (React guarantees setter stability) — OK to omit
// ✓ axios is a module import (stable reference) — OK to omit
}
Common Mistakes
Mistake 1 — Empty array when a dependency is needed
❌ Wrong — effect does not see prop changes:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser); // userId from first render only
}, []); // missing userId → stale if userId changes
}
✅ Correct:
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]); // ✓ re-fetches when userId changes
Mistake 2 — Suppressing the exhaustive-deps warning
❌ Wrong — silencing the lint rule hides the bug:
useEffect(() => {
fetchPosts(tag);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // tag missing — stale bug hidden by suppression
✅ Correct — fix the deps, do not suppress the warning.
Mistake 3 — Object literal directly in dependency array
❌ Wrong — new object on every render causes infinite re-runs:
useEffect(() => { fetchPosts(filters); }, [{ tag, page }]); // always new reference!
✅ Correct — use the primitive values:
useEffect(() => { fetchPosts({ tag, page }); }, [tag, page]); // ✓
Quick Reference
| Dependency | Include in Array? | Reason |
|---|---|---|
| State variable used in effect | Yes | Effect may be stale if state changes |
| Prop used in effect | Yes | Effect may be stale if prop changes |
State setter (setPosts) |
No | React guarantees stable reference |
Module import (axios) |
No | Module references never change |
| Object/array literal | No — use primitives instead | New reference every render causes infinite loops |
| Function defined in component | Only if wrapped in useCallback | Otherwise new reference every render |