Stop Overusing useEffect: Better Patterns
useEffect is the go-to hook for everything, which is why 80% of them shouldn’t exist at all.
The pattern is predictable: developer needs to sync state with something, reaches for useEffect, and causes a render flicker, a double-fetch, or a race condition. Then they spend an hour in the React DevTools profiler trying to figure out why their page loads twice.
useEffect solves a specific problem: keeping something in sync with external systems. Everything else is a misunderstanding of how React works.
Pattern 1: Derived State — Use useMemo, Not useEffect + setState
The Problem: You compute a value from another state, and it goes stale.
// ❌ WRONG: useEffect lag causes stale renderfunction Product({ price, quantity }) { const [total, setTotal] = useState(0);
useEffect(() => { setTotal(price * quantity); }, [price, quantity]);
return <div>Total: ${total}</div>; // Renders old value first}Between render and useEffect firing, the DOM shows the old total. User sees flicker.
// ✅ RIGHT: Derive during renderfunction Product({ price, quantity }) { const total = useMemo(() => price * quantity, [price, quantity]); return <div>Total: ${total}</div>;}useMemo computes during render. No lag. No flicker. Same dependencies array, but synchronous.
Pattern 2: Event Handlers — Don’t useEffect User Actions
The Problem: You respond to user clicks by setting state, and you use useEffect to kick it off.
// ❌ WRONG: useEffect for button clicksfunction SearchForm() { const [query, setQuery] = useState(''); const [results, setResults] = useState([]);
useEffect(() => { if (query) { fetch(`/api/search?q=${query}`) .then(r => r.json()) .then(setResults); } }, [query]);
return ( <> <input onChange={(e) => setQuery(e.target.value)} /> {results.map(r => <div key={r.id}>{r.name}</div>)} </> );}This fetches on every keystroke. You probably want to debounce, which is friction.
// ✅ RIGHT: Handle the action directlyfunction SearchForm() { const [results, setResults] = useState([]);
async function handleSearch(e: React.FormEvent) { const query = new FormData(e.currentTarget).get('q'); const res = await fetch(`/api/search?q=${query}`); setResults(await res.json()); }
return ( <form onSubmit={handleSearch}> <input name="q" type="search" /> {results.map(r => <div key={r.id}>{r.name}</div>)} </form> );}One fetch, on submit, under your control. No multiple renders from state changes. No flicker.
Pattern 3: Data Fetching — Use React Query or SWR
The Problem: useEffect + fetch causes race conditions and double-fetches.
// ❌ WRONG: Race conditions, double-fetchfunction UserProfile({ userId }) { const [user, setUser] = useState(null);
useEffect(() => { let ignore = false; fetch(`/api/users/${userId}`) .then(r => r.json()) .then(data => { if (!ignore) setUser(data); }); return () => { ignore = true; }; }, [userId]);
return <div>{user?.name}</div>;}This works, but you’ve re-invented half of React Query. And it still doesn’t cache, dedup, or invalidate.
// ✅ RIGHT: Use React Queryimport { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) { const { data: user } = useQuery({ queryKey: ['users', userId], queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()), });
return <div>{user?.name}</div>;}React Query handles race conditions, caching, deduplication, background refetching, and stale-while-revalidate. One line of code, production-ready.
Pattern 4: Subscriptions — useEffect IS Right Here
The Problem: You listen to an external system and need to clean up.
This is the only legitimate useEffect use case.
// ✅ RIGHT: useEffect for subscriptionsfunction ChatMessages({ roomId }) { const [messages, setMessages] = useState([]);
useEffect(() => { const socket = io(`/rooms/${roomId}`);
socket.on('message', (msg) => { setMessages(prev => [...prev, msg]); });
return () => { socket.disconnect(); }; }, [roomId]);
return ( <ul> {messages.map(m => <li key={m.id}>{m.text}</li>)} </ul> );}useEffect runs once on mount, sets up the subscription, and cleanup function disconnects. This is correct.
Pattern 5: Lazy Initialization — Use useState with Function
The Problem: Computing initial state is expensive.
// ❌ WRONG: Runs on every renderfunction Counter() { const [count, setCount] = useState(expensiveInit()); // expensiveInit() called on every render return <button onClick={() => setCount(c => c + 1)}>{count}</button>;}// ✅ RIGHT: Lazy init with functionfunction Counter() { const [count, setCount] = useState(() => expensiveInit()); // expensiveInit() called once, on first render return <button onClick={() => setCount(c => c + 1)}>{count}</button>;}Pass a function to useState, not the result. React calls it once, on mount.
The Rule
✔ Event handlers: Just call the function. Don’t useEffect.
✔ Derived state: Use useMemo. Don’t useEffect.
✔ API calls: Use React Query. Don’t useEffect.
✔ Subscriptions: useEffect. This is the right tool.
✔ Browser APIs: useEffect if you’re reading/writing the DOM based on an external event. Otherwise, just call the API.
Summary
The reason useEffect is overused is because it’s the “escape hatch”—it lets you run code outside the render cycle. But that’s also why it’s dangerous: most code should run during render.
If you’re reaching for useEffect to sync state, you’re fighting React. Use useMemo, event handlers, or query libraries instead. Save useEffect for subscriptions and browser APIs.
Audit your codebase. Remove 80% of your useEffects. I guarantee the app gets faster.