useEffect Hook: 6 Common Mistakes Beginners Make
When you're learning React, useEffect is one of the first hooks you'll encounter. It's powerful, but also deceptively easy to misuse. I've seen countless developers struggle with infinite loops, forgotten cleanup functions, and mysterious bugs that only appear in production. The good news? Most mistakes follow predictable patterns. Once you understand what goes wrong and why, you'll write cleaner, more reliable side effects.
This guide walks through the six most common useEffect mistakes that catch beginners off guard, with practical fixes and real-world examples you can apply immediately.
Table of Contents
- Mistake 1: Omitting or Misunderstanding the Dependency Array
- Mistake 2: Using Async Functions Directly
- Mistake 3: Forgetting to Clean Up
- Mistake 4: Violating the Rules of Hooks
- Mistake 5: Creating Infinite Loops
- Mistake 6: Treating Dependencies Carelessly
Mistake 1: Omitting the Dependency Array
The Problem
Many beginners write useEffect without understanding what the dependency array actually does. Without it, your effect runs on every render—which can tank performance and cause bugs you won't catch until production.
Why It Happens
The dependency array isn't intuitive. You might think "I only defined my effect once, why would it run multiple times?" But React's design means your component function runs on every render, including all the code inside it. The dependency array is what tells React "only run this effect when these values actually change."
The Wrong Way
import { useState, useEffect } from 'react';
export function UserProfile() {
const [user, setUser] = useState(null);
// ❌ WRONG: Effect runs on every render
useEffect(() => {
fetch('/api/user').then(r => r.json()).then(setUser);
}); // No dependency array!
return <div>{user?.name}</div>;
}
In this example, the effect runs after every render. This means it fetches user data infinitely, hammering your API and creating unnecessary network requests. Your browser's network tab will look like a waterfall of identical requests.
The Right Way
import { useState, useEffect } from 'react';
export function UserProfile() {
const [user, setUser] = useState(null);
// ✅ CORRECT: Effect runs once on mount
useEffect(() => {
fetch('/api/user').then(r => r.json()).then(setUser);
}, []); // Empty array = run once
return <div>{user?.name}</div>;
}
import { useState, useEffect } from 'react';
export function UserProfile() {
const [user, setUser] = useState(null);
useEffect(() => {
fetch('/api/user').then(r => r.json()).then(setUser);
}, []);
return <div>{user?.name}</div>;
}
The empty dependency array [] tells React: "Run this effect once when the component mounts, then never again." This is the pattern you'll use for one-time initialization tasks like fetching initial data.
When Dependencies Matter
Sometimes you want the effect to run again when something changes:
import { useState, useEffect } from 'react';
interface UserSearchProps {
userId: string;
}
export function UserSearch({ userId }: UserSearchProps) {
const [userData, setUserData] = useState(null);
// ✅ CORRECT: Re-run when userId changes
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(setUserData);
}, [userId]); // Include userId in dependencies
return <div>{userData?.email}</div>;
}
export function UserSearch({ userId }) {
const [userData, setUserData] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(setUserData);
}, [userId]);
return <div>{userData?.email}</div>;
}
Now when userId changes, the effect runs again and fetches the new user's data. Without userId in the dependencies, the component would show stale data.
Mistake 2: Using Async Functions Directly
The Problem
You might instinctively write useEffect(async () => { ... }) because you want to fetch data asynchronously. But React explicitly forbids this pattern—and raises a warning if you try.
Why It Happens
The logic seems sound: "I need async/await, so I'll mark the effect function as async." But useEffect expects its function to return either nothing or a cleanup function. An async function returns a Promise, which breaks that contract. React's warning tells you this pattern doesn't work.
The Wrong Way
import { useState, useEffect } from 'react';
export function DataFetcher() {
const [data, setData] = useState(null);
// ❌ WRONG: Effect function marked as async
useEffect(async () => {
const response = await fetch('/api/data');
const result = await response.json();
setData(result);
}, []);
return <div>{data?.id}</div>;
}
React's warning in the browser console:
Warning: useEffect must not return anything other than a function,
which is used for clean-up. It looks like you wrote useEffect(async () => ...)
or returned a Promise.
The Right Way (Two Patterns)
Pattern 1: Wrap async code in a function and call it
import { useState, useEffect } from 'react';
export function DataFetcher() {
const [data, setData] = useState(null);
// ✅ CORRECT: Create async function inside effect
useEffect(() => {
async function fetchData() {
const response = await fetch('/api/data');
const result = await response.json();
setData(result);
}
fetchData();
}, []);
return <div>{data?.id}</div>;
}
import { useState, useEffect } from 'react';
export function DataFetcher() {
const [data, setData] = useState(null);
useEffect(() => {
async function fetchData() {
const response = await fetch('/api/data');
const result = await response.json();
setData(result);
}
fetchData();
}, []);
return <div>{data?.id}</div>;
}
Pattern 2: Use .then() chains (if you prefer avoiding async/await)
import { useState, useEffect } from 'react';
export function DataFetcher() {
const [data, setData] = useState(null);
// ✅ CORRECT: Using promises directly
useEffect(() => {
fetch('/api/data')
.then(r => r.json())
.then(setData);
}, []);
return <div>{data?.id}</div>;
}
Both patterns avoid marking the effect function itself as async. The effect function stays synchronous and returns nothing (or a cleanup function), which is what React expects.
Mistake 3: Forgetting to Clean Up
The Problem
Many side effects create resources—event listeners, timers, subscriptions, open connections—that need to be cleaned up when the component unmounts or before the effect runs again. Forgetting this causes memory leaks, duplicate listeners, and performance degradation.
Why It Happens
Cleanup functions feel optional. Your code might work fine in development, but memory leaks grow silently in production. Users navigate between pages, components mount and unmount hundreds of times, and suddenly your app is consuming gigabytes of memory.
The Wrong Way
import { useEffect } from 'react';
export function ClickTracker() {
// ❌ WRONG: Event listener is never removed
useEffect(() => {
const handleClick = () => {
console.log('Document clicked');
};
document.addEventListener('click', handleClick);
}, []);
return <div>Open your console and click around</div>;
}
If this component mounts and unmounts (for example, when you navigate to a different page), the event listener stays attached to the document. Revisit the page? Another listener gets added. Do this ten times and you have ten listeners, all firing on every click. Your app becomes increasingly sluggish.
The Right Way
import { useEffect } from 'react';
export function ClickTracker() {
// ✅ CORRECT: Cleanup function removes the listener
useEffect(() => {
const handleClick = () => {
console.log('Document clicked');
};
document.addEventListener('click', handleClick);
// Return a cleanup function
return () => {
document.removeEventListener('click', handleClick);
};
}, []);
return <div>Open your console and click around</div>;
}
import { useEffect } from 'react';
export function ClickTracker() {
useEffect(() => {
const handleClick = () => {
console.log('Document clicked');
};
document.addEventListener('click', handleClick);
return () => {
document.removeEventListener('click', handleClick);
};
}, []);
return <div>Open your console and click around</div>;
}
The cleanup function returned from useEffect runs:
- When the component unmounts
- Before the effect runs again (if dependencies changed)
Common cleanup patterns:
import { useState, useEffect } from 'react';
export function TimerExample() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
// ✅ Cleanup for timers
const interval = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
return <div>Elapsed: {seconds}s</div>;
}
import { useState, useEffect } from 'react';
export function SubscriptionExample() {
const [status, setStatus] = useState('idle');
useEffect(() => {
// ✅ Cleanup for subscriptions
const unsubscribe = window.addEventListener('online', () => {
setStatus('online');
});
return () => {
if (unsubscribe) unsubscribe();
};
}, []);
return <div>Status: {status}</div>;
}
Mistake 4: Violating the Rules of Hooks
The Problem
The Rules of Hooks are non-negotiable. Break them and your component's behavior becomes unpredictable. React's Hook calling order depends on the order of the code, and changing that order (like calling a Hook conditionally) breaks that system.
Rule 1: Don't Call Hooks Conditionally
import { useState, useEffect } from 'react';
interface ConditionalEffectProps {
shouldFetch: boolean;
}
export function ConditionalEffect({ shouldFetch }: ConditionalEffectProps) {
const [data, setData] = useState(null);
// ❌ WRONG: Hook called conditionally
if (shouldFetch) {
useEffect(() => {
fetch('/api/data').then(r => r.json()).then(setData);
}, []);
}
return <div>{data?.id}</div>;
}
This violates the Rules of Hooks. The effect might or might not be set up depending on shouldFetch, causing React to lose track of your hooks.
The fix: Move the condition inside the effect
import { useState, useEffect } from 'react';
interface ConditionalEffectProps {
shouldFetch: boolean;
}
export function ConditionalEffect({ shouldFetch }: ConditionalEffectProps) {
const [data, setData] = useState(null);
// ✅ CORRECT: Hook always runs, condition inside
useEffect(() => {
if (!shouldFetch) return; // Early exit instead of conditional hook
fetch('/api/data').then(r => r.json()).then(setData);
}, [shouldFetch]);
return <div>{data?.id}</div>;
}
export function ConditionalEffect({ shouldFetch }) {
const [data, setData] = useState(null);
useEffect(() => {
if (!shouldFetch) return;
fetch('/api/data').then(r => r.json()).then(setData);
}, [shouldFetch]);
return <div>{data?.id}</div>;
}
Rule 2: Don't Call Hooks Inside Nested Functions
import { useState, useEffect } from 'react';
export function NestedHookExample() {
// ❌ WRONG: Calling useEffect in an event handler
const handleClick = () => {
useEffect(() => {
console.log('Clicked');
});
};
return <button onClick={handleClick}>Click me</button>;
}
The hook runs inside handleClick, not at the top level of the component. React can't track it properly.
The fix: Set up the effect at the component level and use state in the handler
import { useState, useEffect } from 'react';
export function NestedHookExample() {
const [clicked, setClicked] = useState(false);
// ✅ CORRECT: useEffect at component level
useEffect(() => {
if (clicked) {
console.log('Component was clicked');
}
}, [clicked]);
const handleClick = () => {
setClicked(true);
};
return <button onClick={handleClick}>Click me</button>;
}
import { useState, useEffect } from 'react';
export function NestedHookExample() {
const [clicked, setClicked] = useState(false);
useEffect(() => {
if (clicked) {
console.log('Component was clicked');
}
}, [clicked]);
const handleClick = () => {
setClicked(true);
};
return <button onClick={handleClick}>Click me</button>;
}
Mistake 5: Creating Infinite Loops
The Problem
The most common infinite loop happens when you use state in the effect's dependency array but update that same state inside the effect. Every state update triggers the effect again, which updates the state, which triggers the effect... forever.
The Wrong Way
import { useState, useEffect } from 'react';
export function InfiniteLoopExample() {
const [count, setCount] = useState(0);
// ❌ WRONG: Infinite loop
useEffect(() => {
setCount(count + 1); // Updates count
}, [count]); // Re-runs effect when count changes
return <div>Count: {count}</div>;
}
What happens:
- Component mounts, effect runs
setCount(count + 1)updatescountto 1- Since
countchanged, effect runs again setCount(count + 1)updatescountto 2- Since
countchanged, effect runs again - ... repeat forever
Your component will render at maximum speed until React crashes or hangs.
The Right Way
If you want to increment on mount, remove the dependency:
import { useState, useEffect } from 'react';
export function CorrectMount() {
const [count, setCount] = useState(0);
// ✅ CORRECT: Runs once on mount
useEffect(() => {
setCount(c => c + 1); // Use updater function
}, []); // No dependency on count
return <div>Count: {count}</div>;
}
export function CorrectMount() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(c => c + 1);
}, []);
return <div>Count: {count}</div>;
}
If you need to track state changes externally:
import { useState, useEffect } from 'react';
export function TrackChanges() {
const [count, setCount] = useState(0);
// ✅ CORRECT: Reacts to count changes without causing infinite loop
useEffect(() => {
console.log('Count changed to:', count);
// Do something in response to count, but DON'T update count itself
}, [count]);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</div>
);
}
Mistake 6: Treating Dependencies Carelessly
The Problem
Missing or incorrect dependencies cause effects to run at the wrong times or use stale values. This is subtle—your code might work in most cases but fail in specific scenarios you didn't test.
The Wrong Way
import { useState, useEffect } from 'react';
interface SearchProps {
query: string;
}
export function Search({ query }: SearchProps) {
const [results, setResults] = useState([]);
// ❌ WRONG: Missing query in dependencies
useEffect(() => {
if (!query) return;
fetch(`/api/search?q=${query}`)
.then(r => r.json())
.then(setResults);
}, []); // Missing query!
return (
<ul>
{results.map(r => <li key={r.id}>{r.title}</li>)}
</ul>
);
}
Here, the effect runs once on mount and never again, even when query changes. The search always shows results for the initial query.
The Right Way
import { useState, useEffect } from 'react';
interface SearchProps {
query: string;
}
export function Search({ query }: SearchProps) {
const [results, setResults] = useState([]);
// ✅ CORRECT: query included in dependencies
useEffect(() => {
if (!query) {
setResults([]);
return;
}
fetch(`/api/search?q=${query}`)
.then(r => r.json())
.then(setResults);
}, [query]); // Include query in dependencies
return (
<ul>
{results.map(r => <li key={r.id}>{r.title}</li>)}
</ul>
);
}
export function Search({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
if (!query) {
setResults([]);
return;
}
fetch(`/api/search?q=${query}`)
.then(r => r.json())
.then(setResults);
}, [query]);
return (
<ul>
{results.map(r => <li key={r.id}>{r.title}</li>)}
</ul>
);
}
Now when query changes, the effect re-runs and fetches results for the new query.
Objects and Functions in Dependencies
Be especially careful with object and function dependencies. A new object or function created on every render will trigger the effect on every render:
import { useState, useEffect } from 'react';
export function ProblematicConfig() {
const [data, setData] = useState(null);
// ❌ WRONG: Config object created on every render
useEffect(() => {
const config = { headers: { 'Content-Type': 'application/json' } };
fetch('/api/data', config)
.then(r => r.json())
.then(setData);
}, [config]); // But config is new every render!
}
Fix: Move objects outside the effect or memoize them
import { useState, useEffect } from 'react';
export function CorrectConfig() {
const [data, setData] = useState(null);
// ✅ CORRECT: Config object defined outside effect
const config = { headers: { 'Content-Type': 'application/json' } };
useEffect(() => {
fetch('/api/data', config)
.then(r => r.json())
.then(setData);
}, []); // Stable reference
return <div>{data?.id}</div>;
}
Practical Application: Real-World User Profile Component
Consider a practical scenario common in e-commerce platforms (like Alibaba or ByteDance internal dashboards): Building a user profile component that fetches user data when a user ID prop changes and cleans up subscriptions.
import { useState, useEffect } from 'react';
import { ReactNode } from 'react';
interface UserProfile {
id: string;
name: string;
email: string;
preferences: {
notifications: boolean;
theme: 'light' | 'dark';
};
}
interface UserProfileCardProps {
userId: string;
onLoadComplete?: () => void;
children?: ReactNode;
}
export function UserProfileCard({ userId, onLoadComplete, children }: UserProfileCardProps) {
const [profile, setProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Guard against empty userId
if (!userId) {
setProfile(null);
setError(null);
return;
}
setLoading(true);
setError(null);
// Create abort controller for cleanup
const controller = new AbortController();
// Async function wrapped inside effect
async function fetchUserProfile() {
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Failed to load user: ${response.status}`);
}
const data: UserProfile = await response.json();
setProfile(data);
onLoadComplete?.();
} catch (err) {
// Ignore abort errors (component unmounted)
if (err instanceof Error && err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setLoading(false);
}
}
fetchUserProfile();
// Cleanup: Abort ongoing requests
return () => {
controller.abort();
};
}, [userId, onLoadComplete]);
if (loading) return <div>Loading profile...</div>;
if (error) return <div>Error: {error}</div>;
if (!profile) return <div>No profile loaded</div>;
return (
<div className="profile-card">
<h2>{profile.name}</h2>
<p>Email: {profile.email}</p>
<p>Theme: {profile.preferences.theme}</p>
{children}
</div>
);
}
import { useState, useEffect } from 'react';
export function UserProfileCard({ userId, onLoadComplete, children }) {
const [profile, setProfile] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!userId) {
setProfile(null);
setError(null);
return;
}
setLoading(true);
setError(null);
const controller = new AbortController();
async function fetchUserProfile() {
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Failed to load user: ${response.status}`);
}
const data = await response.json();
setProfile(data);
onLoadComplete?.();
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setLoading(false);
}
}
fetchUserProfile();
return () => {
controller.abort();
};
}, [userId, onLoadComplete]);
if (loading) return <div>Loading profile...</div>;
if (error) return <div>Error: {error}</div>;
if (!profile) return <div>No profile loaded</div>;
return (
<div className="profile-card">
<h2>{profile.name}</h2>
<p>Email: {profile.email}</p>
<p>Theme: {profile.preferences.theme}</p>
{children}
</div>
);
}
Key patterns demonstrated:
- Proper async handling with cleanup wrapper
- AbortController for request cancellation (prevents updating unmounted components)
- Guard clause for empty data (
if (!userId) return;) - Error handling with AbortError filtering
- Dependencies array includes all used variables
- Optional callback fires only on successful load
FAQ
Q: When should I use useEffect vs. just putting code in the component body?
A: Code in the component body runs on every render. Use useEffect for side effects—anything that's not directly computing render output. Examples: fetching data, setting up event listeners, modifying the DOM, managing subscriptions. Keep the component body for pure calculations only.
Q: Why does React warn about missing dependencies?
A: Missing dependencies mean your effect is using outdated values. You might fetch data with an old query, access state from three renders ago, or call an event handler that doesn't exist anymore. React's warning helps you catch these bugs before users encounter them.
Q: Can I safely ignore ESLint warnings about missing dependencies?
A: Rarely. There are legitimate exceptions (like when you're intentionally using a stable callback), but they require explicit comment overrides. If you're tempted to ignore the warning, first ask: "Why is this dependency missing?" Usually, adding it is the right answer.
Q: What's the difference between returning undefined and returning a cleanup function?
A: Both are valid. If your effect doesn't create resources to clean up, returning nothing is fine. If it creates listeners, timers, or subscriptions, return a function that cleans them up. Think of it as: "For every resource I create, I need a cleanup function to destroy it."
Q: Do I need to worry about cleanup if I'm just fetching data with fetch()?
A: Not for the fetch itself, but you should use AbortController to cancel in-flight requests when the component unmounts or dependencies change. This prevents "can't update a component in an unmounted state" warnings and wasted network traffic.
Related Articles
- useState Hook: Managing Component State
- Building Custom Hooks: Reuse Effect Logic
- React Performance Optimization Patterns
- Event Handling in React: Avoiding Common Pitfalls
Questions? Share your useEffect struggles in the comments below. What mistakes have you made? What patterns have worked best for you? I'd love to hear about real-world scenarios you've encountered and how you solved them.
Google AdSense Placeholder
CONTENT SLOT