If you've been writing React for a while, you know the feeling.
Something's not working. Data isn't updating. A bug appears out of nowhere.
And somewhere in your codebase, there's a useEffect quietly causing chaos.
Here's the truth senior React developers know: most useEffect calls shouldn't exist.
I went through a mid-sized React project last month and removed 80% of the useEffect calls. The app got faster, the code got cleaner, and the bugs disappeared.
This article shows you exactly what I used instead. 🚀
Why useEffect Gets Overused
When you first learn React, useEffect feels like a superpower.
Need to fetch data? useEffect.
Need to update something when state changes? useEffect.
Not sure what's happening? Throw in a useEffect and hope for the best.
The problem? useEffect was never meant to be your default tool. It was designed for synchronizing with external systems — things like DOM APIs, third-party libraries, and network requests that React doesn't control.
Using it for everything else causes:
- ❌ Infinite re-render loops
- ❌ Stale closure bugs
- ❌ Race conditions in data fetching
- ❌ Code that's hard to read and debug
Let's fix that.
Alternative 1: useMemo — For Expensive Calculations
The wrong way — using useEffect:
// ❌ Don't do this
const [filteredUsers, setFilteredUsers] = useState([]);
useEffect(() => {
const result = users.filter(user => user.isActive);
setFilteredUsers(result);
}, [users]);
What's wrong here? You're using useEffect to set state based on other state. This causes an extra render cycle every single time.
The right way — using useMemo:
// ✅ Do this instead
const filteredUsers = useMemo(() => {
return users.filter(user => user.isActive);
}, [users]);
useMemo computes the value during render, not after it. No extra render cycle. No state update. Just pure, clean derived data.
When to use useMemo:
- Filtering or sorting large arrays
- Complex calculations based on props or state
- Creating derived data from existing state
💡 Rule of thumb: If you're calling
setXinside auseEffectbased on other state/props, you almost certainly wantuseMemoinstead.
Alternative 2: React 19's use() Hook — For Data Fetching
React 19 introduced the use() hook, and it changes everything about how we fetch data.
The old way — useEffect for fetching:
// ❌ Classic useEffect fetching — messy
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(data => {
setData(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, []);
That's 15+ lines just to fetch data. And it still has race condition issues.
The new way — React 19's use() hook:
// ✅ Clean, modern, and simple
import { use, Suspense } from 'react';
const usersPromise = fetch('/api/users').then(res => res.json());
function UserList() {
const users = use(usersPromise);
return (
<ul>
{users.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
// Wrap with Suspense in parent
function App() {
return (
<Suspense fallback={<p>Loading...</p>}>
<UserList />
</Suspense>
);
}
No loading state. No error state boilerplate. React handles it automatically with Suspense.
💡 Note:
use()works with React 19+. Check your React version before using it.
Alternative 3: TanStack Query — For Serious Data Fetching
For production apps, TanStack Query (formerly React Query) is what senior devs reach for instead of useEffect fetching.
The useEffect way — full of problems:
// ❌ This has race conditions, no caching, no refetching
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setUser);
}, [userId]);
The TanStack Query way:
// ✅ Caching, refetching, loading states — all handled
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
});
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error loading user</p>;
return <div>{user.name}</div>;
}
With TanStack Query you automatically get:
- ✅ Caching — same data isn't fetched twice
- ✅ Background refetching — data stays fresh
- ✅ Loading & error states — built in
- ✅ Race condition prevention — handled automatically
- ✅ Pagination & infinite scroll — easy to implement
💡 Install:
npm install @tanstack/react-query
Alternative 4: Direct Render Calculation — The Simplest Fix
This one is embarrassingly simple, and yet so many developers miss it.
If you're using useEffect just to compute something and store it in state — you don't need useEffect or useMemo. Just calculate it directly during render.
// ❌ Over-engineered with useEffect
const [totalPrice, setTotalPrice] = useState(0);
useEffect(() => {
const total = cartItems.reduce((sum, item) => sum + item.price, 0);
setTotalPrice(total);
}, [cartItems]);
// ❌ Still over-engineered with useMemo (for simple cases)
const totalPrice = useMemo(() => {
return cartItems.reduce((sum, item) => sum + item.price, 0);
}, [cartItems]);
// ✅ Just... calculate it
const totalPrice = cartItems.reduce((sum, item) => sum + item.price, 0);
That's it. React recalculates it on every render — and that's completely fine for simple calculations.
Only use useMemo when:
- The calculation is genuinely expensive (thousands of items)
- You've profiled it and confirmed it's a performance bottleneck
For everything else, just calculate it inline.
Alternative 5: useLoaderData from React Router — For Route-Level Fetching
If you're using React Router v6.4+, you're probably still fetching data inside components with useEffect. Stop.
React Router has a built-in solution called loaders that fetches data before the component renders.
The old way — fetching inside component:
// ❌ Data fetches AFTER render — causes loading flicker
function UserPage() {
const { userId } = useParams();
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setUser);
}, [userId]);
if (!user) return <p>Loading...</p>;
return <div>{user.name}</div>;
}
The React Router way — loader + useLoaderData:
// ✅ Data fetches BEFORE render — no loading flicker
// Step 1: Define loader outside component
export async function loader({ params }) {
const user = await fetch(`/api/users/${params.userId}`).then(r => r.json());
return { user };
}
// Step 2: Use the data inside component
import { useLoaderData } from 'react-router-dom';
function UserPage() {
const { user } = useLoaderData();
return <div>{user.name}</div>; // Data is already here!
}
// Step 3: Attach loader to route
const router = createBrowserRouter([
{
path: '/users/:userId',
element: <UserPage />,
loader: loader,
}
]);
The component renders with data already available. No loading state needed. No useEffect. No flickering UI.
The Decision Framework — Which One Should You Use?
Next time you're about to write useEffect, ask yourself:
Am I computing something from existing state/props?
→ YES: Use useMemo or direct calculation
→ NO ↓
Am I fetching data for a route?
→ YES: Use useLoaderData (React Router)
→ NO ↓
Am I fetching data in a component?
→ YES: Use TanStack Query or React 19's use()
→ NO ↓
Am I syncing with an external system (DOM, WebSocket, third-party lib)?
→ YES: useEffect is correct here ✅
→ NO: Rethink your approach
Quick Cheat Sheet
| Situation | Use This |
|---|---|
| Derived data from state/props |
useMemo or direct calculation |
| Simple data fetching (React 19) |
use() hook |
| Production data fetching | TanStack Query |
| Route-level data fetching | useLoaderData |
| External system sync |
useEffect ✅ |
The Real Lesson
useEffect isn't bad. It's just misused.
It's a precision tool for syncing React with the outside world — not a general-purpose Swiss Army knife for every side effect imaginable.
When you start reaching for the right tool for each job, your React code becomes:
- 🚀 Faster (fewer unnecessary renders)
- 🧹 Cleaner (less boilerplate)
- 🐛 More reliable (fewer subtle bugs)
Start small. Pick one useEffect in your current project and ask: "Does this actually need to be here?"
You might be surprised by the answer.
Which of these alternatives are you going to try first? Or do you have a useEffect horror story from your own codebase? Drop it in the comments — I'd love to hear it! 👇
Heads up: AI helped me write this.But the ideas, code review, and learning are all mine — AI just helped me communicate them better. I believe in being transparent about my process! 😊
Top comments (2)
Great practical guide! I've suffered from useEffect state management and racing issues across many projects myself. The decision framework at the end is especially helpful having a clear mental model of "when to actually use useEffect" would have saved me hours of debugging stale closures and infinite loops. TanStack Query was a game-changer for us too. Once we switched, the amount of boilerplate we eliminated was shocking
Totally agree with you! 🙌
useEffect can quickly become messy if we don’t have a clear mental model. I’ve also wasted hours debugging stale closures and unexpected re-renders.
That’s exactly why tools like TanStack Query feel like a breath of fresh air — less boilerplate, fewer bugs, and way more focus on actual product logic instead of state juggling.
Glad this resonated with you!