Skip to main content
btheo.com btheo.com > press start to play
NEW POST: NODE.JS SECURITY 2025 OPEN FOR FREELANCE 10+ YEARS EXP REACT × NODE × AWS NEW POST: NODE.JS SECURITY 2025 OPEN FOR FREELANCE 10+ YEARS EXP REACT × NODE × AWS
REACT 4 MIN READ

Stop Overusing useEffect: Better Patterns

WARNING · DRAGON AHEAD

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 render
function 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 render
function 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 clicks
function 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 directly
function 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-fetch
function 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 Query
import { 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 subscriptions
function 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 render
function Counter() {
const [count, setCount] = useState(expensiveInit());
// expensiveInit() called on every render
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
// ✅ RIGHT: Lazy init with function
function 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.

ALL POSTS →