5 Common React useEffect Mistakes and How to Fix Them
On this page
useEffect is the most misunderstood hook in React. It looks simple but hides complexity that catches even experienced developers. These are the five mistakes I see most often in code reviews, with clear fixes for each.
TL;DR: Stop making these useEffect mistakes. Learn the correct patterns for data fetching, cleanup, dependency arrays, and avoiding infinite loops.
Mistake 1: Missing Cleanup Functions
Effects that create subscriptions, timers, or event listeners without cleaning them up cause memory leaks and stale state bugs:
// BUG: WebSocket connection is never closed
function ChatRoom({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState<Message[]>([]);
useEffect(() => {
const ws = new WebSocket(`wss://chat.example.com/${roomId}`);
ws.onmessage = (event) => {
setMessages((prev) => [...prev, JSON.parse(event.data)]);
};
// No cleanup! When roomId changes, the old connection stays open
// and keeps pushing messages to state
}, [roomId]);
return <MessageList messages={messages} />;
}
When roomId changes, React runs the effect again, creating a new WebSocket. But the old one is still open, receiving messages, and calling setMessages on a stale closure.
Fix: Return a Cleanup Function
function ChatRoom({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState<Message[]>([]);
useEffect(() => {
const ws = new WebSocket(`wss://chat.example.com/${roomId}`);
ws.onmessage = (event) => {
setMessages((prev) => [...prev, JSON.parse(event.data)]);
};
// Cleanup: close the connection when roomId changes or component unmounts
return () => {
ws.close();
};
}, [roomId]);
return <MessageList messages={messages} />;
}
Rule of thumb: If your effect creates something (connection, subscription, timer, observer), it must return a function that destroys it.
Mistake 2: Infinite Loops from Object Dependencies
Passing objects or arrays as dependencies causes infinite re-renders because React compares them by reference:
// BUG: Infinite loop
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
// This object is recreated on every render
const options = {
includeAvatar: true,
includeStats: true,
};
useEffect(() => {
fetchUser(userId, options).then(setUser);
}, [userId, options]); // options is a new object every render = infinite loop
return <Profile user={user} />;
}
Every render creates a new options object with a new reference. React sees it as "changed" and re-runs the effect, which triggers a state update, which causes a re-render, which creates a new options object...
Fix: Hoist Constants or Use useMemo
// Option A: Move constant objects outside the component
const FETCH_OPTIONS = {
includeAvatar: true,
includeStats: true,
} as const;
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetchUser(userId, FETCH_OPTIONS).then(setUser);
}, [userId]); // FETCH_OPTIONS is stable, no need to include it
return <Profile user={user} />;
}
// Option B: If options depend on props/state, use useMemo
function UserProfile({ userId, showAvatar }: Props) {
const [user, setUser] = useState<User | null>(null);
const options = useMemo(
() => ({ includeAvatar: showAvatar, includeStats: true }),
[showAvatar]
);
useEffect(() => {
fetchUser(userId, options).then(setUser);
}, [userId, options]);
return <Profile user={user} />;
}
// Option C: Use primitive dependencies instead
function UserProfile({ userId, showAvatar }: Props) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetchUser(userId, {
includeAvatar: showAvatar,
includeStats: true,
}).then(setUser);
}, [userId, showAvatar]); // Primitives are compared by value
return <Profile user={user} />;
}
Option C is usually the cleanest. Depend on the primitive values that drive the object, not the object itself.
Mistake 3: Racing Conditions in Data Fetching
When a component fetches data based on props, rapid changes can cause out-of-order responses:
// BUG: Race condition
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<Result[]>([]);
useEffect(() => {
searchAPI(query).then((data) => {
setResults(data); // What if an older request finishes AFTER a newer one?
});
}, [query]);
return <ResultList results={results} />;
}
User types "react hooks" fast. The search for "react" takes 500ms, the search for "react hooks" takes 200ms. "react hooks" results appear first, then get overwritten by the slower "react" results. The UI shows wrong results.
Fix: Use an Abort Flag or AbortController
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<Result[]>([]);
useEffect(() => {
let cancelled = false;
searchAPI(query).then((data) => {
if (!cancelled) {
setResults(data);
}
});
return () => {
cancelled = true; // Ignore results from stale requests
};
}, [query]);
return <ResultList results={results} />;
}
Even better, use AbortController to actually cancel the HTTP request:
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<Result[]>([]);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then((res) => res.json())
.then(setResults)
.catch((err) => {
if (err.name !== 'AbortError') throw err;
});
return () => controller.abort();
}, [query]);
return <ResultList results={results} />;
}
Mistake 4: Using useEffect for Derived State
This is the most overused pattern. Calculating values from props or state does not need an effect:
// WRONG: useEffect for derived state
function FilteredList({ items, filter }: Props) {
const [filteredItems, setFilteredItems] = useState(items);
useEffect(() => {
setFilteredItems(items.filter((item) => item.category === filter));
}, [items, filter]);
return <List items={filteredItems} />;
}
This is wasteful. It renders once with stale data, then triggers a second render with the filtered data. Every filter change causes two renders instead of one.
Fix: Calculate During Render
// CORRECT: Calculate during render
function FilteredList({ items, filter }: Props) {
const filteredItems = items.filter((item) => item.category === filter);
return <List items={filteredItems} />;
}
If the calculation is expensive, use useMemo:
function FilteredList({ items, filter }: Props) {
const filteredItems = useMemo(
() => items.filter((item) => item.category === filter),
[items, filter]
);
return <List items={filteredItems} />;
}
Rule: If you can calculate something from existing props and state, do not put it in state. And definitely do not use useEffect to "sync" it.
Mistake 5: Ignoring the Exhaustive Dependencies Rule
The ESLint rule react-hooks/exhaustive-deps exists for a reason. Developers often suppress it instead of fixing the underlying issue:
// DANGEROUS: suppressing the lint rule
function AutoSave({ data, onSave }: Props) {
useEffect(() => {
const timer = setTimeout(() => {
onSave(data); // Uses data and onSave but only depends on data
}, 1000);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]); // Missing onSave in dependencies
}
If onSave changes (because the parent re-renders with a new callback), the effect will call the stale version.
Fix: Stabilize the Callback
function AutoSave({ data, onSave }: Props) {
// Option A: wrap onSave with useCallback in the parent
// Option B: use useRef to always have the latest callback
const onSaveRef = useRef(onSave);
onSaveRef.current = onSave;
useEffect(() => {
const timer = setTimeout(() => {
onSaveRef.current(data);
}, 1000);
return () => clearTimeout(timer);
}, [data]); // Now this is genuinely correct — ref.current is always fresh
}
Or with React 19's useEffectEvent (experimental):
function AutoSave({ data, onSave }: Props) {
const handleSave = useEffectEvent(() => {
onSave(data);
});
useEffect(() => {
const timer = setTimeout(handleSave, 1000);
return () => clearTimeout(timer);
}, [data]);
}
When You Actually Need useEffect
After all these mistakes, you might wonder when useEffect is appropriate. Here are the valid use cases:
- Synchronizing with external systems: WebSocket connections, third-party libraries, DOM APIs
- Fetching data: Though consider React Query, SWR, or server components first
- Setting up subscriptions: Browser events, resize observers, intersection observers
- Analytics and logging: Track page views, user actions
You do NOT need useEffect for:
- Transforming data for rendering (calculate during render)
- Handling user events (use event handlers)
- Resetting state when props change (use a
keyprop) - Initializing the app (run at module scope or in an event handler)
Key Takeaways
- Always return a cleanup function when effects create resources
- Depend on primitive values, not objects, to avoid infinite loops
- Use
AbortControlleror cancellation flags to handle race conditions - Never use
useEffectto compute derived state — just calculate it during render - Never suppress the exhaustive-deps rule — fix the root cause instead
Sources
Related Articles
How to Debug Node.js Memory Leaks (Step-by-Step Guide)
Learn how to detect, diagnose, and fix Node.js memory leaks using heap snapshots, Chrome DevTools, and clinic.js — with real code examples.
Building an AI Chatbot With LangChain: Practical Developer Guide
Build a production-ready AI chatbot with LangChain, Python, and OpenAI. Step-by-step guide with memory, RAG, streaming, and deployment tips.
How to Fix CORS Errors in Node.js and Express (Complete Guide)
Master CORS errors in Express. Learn what causes them, how to fix them, and best practices for production APIs with practical examples.