I'm going to say something controversial: most useEffect calls I see in code reviews don't need to exist.
Not because the developer wrote bad code. But because useEffect feels like the natural answer to a lot of problems it wasn't actually designed to solve. You need to do something after state changes? useEffect. You need to reset something when a prop changes? useEffect. You need to call a function after a user clicks a button? useEffect.
I was guilty of all of these. For years. And it wasn't until I really sat down with the React docs — specifically the page called You Might Not Need an Effect — that I understood what this hook is actually for.
This article is a practical breakdown of the most common useEffect misuses I've seen, and exactly what to do instead.
What is useEffect actually for?
Before we get into the anti-patterns, let's be clear about what useEffect is designed for.
useEffect is for synchronizing your component with an external system. That's it. Browser APIs like ResizeObserver, WebSockets, third-party libraries that need to be set up and torn down, timers that need cleanup — these are legitimate use cases.
If what you're doing doesn't involve an external system, you probably don't need useEffect.
Mistake #1: Deriving state from other state or props
This is the most common one. You have some state, you want to compute something from it, and you reach for useEffect to keep them in sync.
// ❌ Don't do this
function ProductList({ products }: { products: Product[] }) {
const [filteredProducts, setFilteredProducts] = useState(products);
useEffect(() => {
setFilteredProducts(products.filter((p) => p.inStock));
}, [products]);
return (
<ul>
{filteredProducts.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
What's wrong here? This causes two renders instead of one. React renders with the old filteredProducts, then the effect runs, updates the state, and React renders again. You're also storing derived data in state, which is almost always unnecessary.
// ✅ Do this instead
function ProductList({ products }: { products: Product[] }) {
const filteredProducts = products.filter((p) => p.inStock);
return (
<ul>
{filteredProducts.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
Just compute it during render. One render, no effect, no extra state.
If the computation is genuinely expensive (sorting 50,000 records, complex transformations), then useMemo is appropriate. But filtering a normal-sized array? Just do it inline.
Mistake #2: Resetting state when a prop changes
You want to clear a text field when the user switches to a different item. Naturally, you write this:
// ❌ Don't do this
function CommentBox({ postId }: { postId: string }) {
const [comment, setComment] = useState('');
useEffect(() => {
setComment('');
}, [postId]);
return (
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
);
}
Same problem as before — an extra render. The component first renders with the stale comment, then the effect fires and clears it, and React renders again.
The correct solution is one of the most underused tricks in React: the key prop.
// ✅ Do this instead
<CommentBox key={postId} postId={postId} />
When key changes, React unmounts the component entirely and mounts a fresh one. All local state resets automatically. No useEffect, no extra render, one line of code.
I use this trick constantly. Once you know about it, you'll see opportunities for it everywhere.
Mistake #3: Reacting to user events inside useEffect
This one looks obviously wrong when you see it written out, but I've seen it more times than I can count:
// ❌ Don't do this
function Form({ onSuccess }: { onSuccess: () => void }) {
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (submitted) {
onSuccess();
}
}, [submitted, onSuccess]);
return (
<button onClick={() => setSubmitted(true)}>
Submit
</button>
);
}
Why would you do this? If something needs to happen because the user clicked a button, put it in the click handler. That's what click handlers are for.
// ✅ Do this instead
function Form({ onSuccess }: { onSuccess: () => void }) {
const handleClick = () => {
onSuccess();
};
return (
<button onClick={handleClick}>
Submit
</button>
);
}
The rule is simple: if it's caused by a user action, handle it in an event handler.
Mistake #4: Effect chains
This is where things get really messy. One useEffect sets some state, which triggers another useEffect, which sets more state, which triggers another...
// ❌ A chain of effects — a debugging nightmare
useEffect(() => {
if (userId) {
setIsLoadingUser(true);
fetchUser(userId).then((user) => {
setUser(user);
setIsLoadingUser(false);
});
}
}, [userId]);
useEffect(() => {
if (user) {
setPermissions(calculatePermissions(user.role));
}
}, [user]);
useEffect(() => {
if (permissions) {
setNavItems(buildNavigation(permissions));
}
}, [permissions]);
Every link in this chain adds another render cycle. You have cascading state updates, potential race conditions, and code that's nearly impossible to trace when something goes wrong.
If you find yourself in this situation, step back and ask: can I compute this in a single place?
// ✅ Derive everything from the source of truth
useEffect(() => {
if (!userId) return;
fetchUser(userId).then((user) => {
const permissions = calculatePermissions(user.role);
const navItems = buildNavigation(permissions);
setUser(user);
setPermissions(permissions);
setNavItems(navItems);
});
}, [userId]);
Or better yet — use TanStack Query and get rid of the manual loading state management entirely (more on that below).
Mistake #5: Fetching data without proper cleanup
Data fetching is one of the legitimate uses of useEffect. But most implementations you'll find in the wild are broken in a subtle way:
// ❌ This has a race condition
function UserCard({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetchUser(userId).then((data) => setUser(data));
}, [userId]);
return user ? <div>{user.name}</div> : <p>Loading...</p>;
}
The problem: if userId changes before the first fetch resolves, both requests are in flight simultaneously. Whichever finishes last wins. You might render data for the wrong user.
The fix is to ignore stale responses:
// ✅ With cleanup — ignores stale responses
useEffect(() => {
let cancelled = false;
fetchUser(userId).then((data) => {
if (!cancelled) setUser(data);
});
return () => {
cancelled = true;
};
}, [userId]);
But honestly? In 2026, you shouldn't be writing raw data fetching in useEffect at all. Use TanStack Query. It handles caching, deduplication, cancellation, and background refetching out of the box. You literally can't forget the cleanup because the library handles it:
// ✅ Just use TanStack Query
import { useQuery } from '@tanstack/react-query';
function UserCard({ userId }: { userId: string }) {
const { data: user, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
return isLoading ? <p>Loading...</p> : <div>{user?.name}</div>;
}
No cleanup, no race conditions, automatic caching, background refetching when you return to the tab. It's not even a close comparison.
So when DO you need useEffect?
Here's the short list of genuinely appropriate useEffect uses:
- Setting up a WebSocket connection and cleaning it up on unmount
- Subscribing to a browser API like ResizeObserver, IntersectionObserver, or addEventListener
- Integrating a third-party library that directly manipulates the DOM (like a chart library or map)
- Syncing with localStorage or sessionStorage on mount
- Analytics page view tracking when a route changes
Notice the pattern: all of these involve talking to something outside of React. That's the rule. If you're only dealing with React state and props, you probably don't need useEffect.
The rules I follow
Let me summarize with the practical rules I've internalized:
If you can compute it during render, don't store it in state. Derived values belong in the render function, not in state + useEffect.
If it's triggered by a user event, handle it in the event handler. onClick, onChange, onSubmit — these are where your logic belongs.
If you need to reset state when a prop changes, use the key prop. Not useEffect. Just change the key.
If you're fetching data, use TanStack Query or SWR. Don't reinvent this with raw useEffect.
If you're synchronizing with an external system, useEffect is correct. That's literally what it's for.
Wrapping up
useEffect is a powerful hook that gets misused because it seems like the right tool for so many problems. But most of the time, the real solution is simpler: compute during render, handle events in handlers, or use a library that's designed for the specific problem you're solving.
Once you internalize these rules, you'll find your components getting smaller, faster, and much easier to debug. The double renders disappear. The cascading state updates disappear. The race conditions disappear.
It's one of those things where less code genuinely means better code.
This article is based on a chapter from my book "Best Ways to Improve Your React Project — 2026 Edition" — a guide for mid-to-senior React developers covering architecture, patterns, performance, React 19 APIs, testing, and more.
If you found this useful, the book has 13 more chapters like it.
→ PDF on Gumroad (use code LAUNCH for a 40% launch discount): https://gabrielenache.gumroad.com/l/best-ways-to-improve-your-react-project
Top comments (0)