dont-use-use-effect
Don't Use useEffect
useEffect is one of the most misused hooks in React. In the vast majority of cases where developers reach for it, there is a simpler, more performant, and more correct alternative. Every unnecessary useEffect introduces an extra render cycle, increases the risk of bugs (stale closures, race conditions, infinite loops), and makes components harder to reason about.
The golden rule: Effects are for synchronizing with external systems (DOM APIs, timers, websockets, third-party widgets). If the work you're doing is a response to a user event, or can be calculated from existing state/props, you don't need an Effect.
1. Derived State — Just Calculate It
The most common useEffect anti-pattern: storing a value in state that can be computed from other state or props.
// 🔴 BAD: Redundant state + unnecessary Effect
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
return <span>{fullName}</span>;
}
This causes two renders every time firstName or lastName changes: one with the stale fullName, and another after the Effect updates it.
// ✅ GOOD: Calculate during render
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// Derived — no state, no Effect, one render
const fullName = firstName + ' ' + lastName;
return <span>{fullName}</span>;
}
For expensive calculations, wrap in useMemo instead of storing in state:
// ✅ GOOD: Memoize expensive derivations
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// Only recomputes when todos or filter change, not when newTodo changes
const visibleTodos = useMemo(
() => getFilteredTodos(todos, filter),
[todos, filter]
);
return <ul>{visibleTodos.map(todo => <li key={todo.id}>{todo.text}</li>)}</ul>;
}
2. Event-Driven Logic — Use Event Handlers
If code should run because the user did something (clicked, submitted, typed), it belongs in an event handler — not an Effect. Effects run because a component rendered, not because the user took an action.
// 🔴 BAD: Event-specific logic triggered by state change in an Effect
function ProductPage({ product, addToCart }) {
useEffect(() => {
if (product.isInCart) {
showNotification(`Added ${product.name} to the cart!`);
}
}, [product]);
function handleBuyClick() {
addToCart(product);
}
// ...
}
This is buggy: the notification fires on page reload if the product is already in the cart.
// ✅ GOOD: Side effects belong in the event handler that caused them
function ProductPage({ product, addToCart }) {
function buyProduct() {
addToCart(product);
showNotification(`Added ${product.name} to the cart!`);
}
function handleBuyClick() {
buyProduct();
}
function handleCheckoutClick() {
buyProduct();
navigateTo('/checkout');
}
// ...
}
Rule of thumb: If you can point to the exact user interaction that should trigger the code, put it in that interaction's event handler.
3. Effect Chains — Consolidate Into Event Handlers
Chaining Effects that each set state based on other state creates a cascade of unnecessary re-renders and rigid, fragile code.
// 🔴 BAD: Chain of Effects triggering each other (4 render passes!)
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
const [isGameOver, setIsGameOver] = useState(false);
useEffect(() => {
if (card !== null && card.gold) {
setGoldCardCount(c => c + 1);
}
}, [card]);
useEffect(() => {
if (goldCardCount > 3) {
setRound(r => r + 1);
setGoldCardCount(0);
}
}, [goldCardCount]);
useEffect(() => {
if (round > 5) setIsGameOver(true);
}, [round]);
// ...
}
// ✅ GOOD: Derive what you can, compute the rest in the event handler
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
// Derived — no state needed
const isGameOver = round > 5;
function handlePlaceCard(nextCard) {
if (isGameOver) throw Error('Game already ended.');
setCard(nextCard);
if (nextCard.gold) {
if (goldCardCount < 3) {
setGoldCardCount(goldCardCount + 1);
} else {
setGoldCardCount(0);
setRound(round + 1);
if (round === 5) alert('Good game!');
}
}
}
// ...
}
4. Data Fetching — Use a Data-Fetching Library
Fetching data in useEffect is fragile. You must manually handle race conditions, loading states, caching, error handling, and cleanup. Raw useEffect fetching has no caching, no deduplication, and no SSR support.
// 🔴 BAD: Manual fetch in useEffect — race conditions, no caching, no SSR
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
let ignore = false;
setIsLoading(true);
fetchResults(query).then(json => {
if (!ignore) {
setResults(json);
setIsLoading(false);
}
});
return () => { ignore = true; };
}, [query]);
// ...
}
// ✅ GOOD: Use TanStack Query (or your framework's data primitive)
import { useQuery } from '@tanstack/react-query';
function SearchResults({ query }) {
const { data: results = [], isLoading } = useQuery({
queryKey: ['search', query],
queryFn: () => fetchResults(query),
enabled: !!query,
});
// Automatic caching, deduplication, race-condition handling,
// background refetching, and SSR support — for free.
}
Other good alternatives:
- Next.js / Remix:
loaderfunctions, Server Components,use()+ Suspense - SWR:
useSWR(key, fetcher)— similar to TanStack Query - tRPC: End-to-end type-safe fetching with built-in React Query integration
If you must use useEffect for fetching (no framework, no library), at minimum:
- Add the
ignorecleanup flag to prevent race conditions - Extract the logic into a reusable custom hook (e.g.
useData(url)) - Handle loading, error, and empty states explicitly
5. External Store Subscriptions — Use useSyncExternalStore
Subscribing to an external data source (browser API, third-party store, observable) using useEffect + setState is both boilerplate-heavy and can cause tearing in concurrent mode.
// 🔴 BAD: Manual subscription with useEffect
function OnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return <span>{isOnline ? '✅ Online' : '❌ Offline'}</span>;
}
// ✅ GOOD: useSyncExternalStore — concurrent-safe, less code
import { useSyncExternalStore } from 'react';
function subscribe(callback: () => void) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function getSnapshot() {
return navigator.onLine;
}
function OnlineStatus() {
const isOnline = useSyncExternalStore(subscribe, getSnapshot);
return <span>{isOnline ? '✅ Online' : '❌ Offline'}</span>;
}
For state management libraries, use their built-in React hooks instead of manual subscriptions. See the react-render-performance skill for selector-based patterns with XState, Zustand, Redux, and Nanostores.
6. Resetting State on Prop Changes — Use a key
Don't use useEffect to reset state when a prop changes. React has a built-in mechanism: the key prop.
// 🔴 BAD: Resetting state in an Effect
function EditProfile({ userId }) {
const [comment, setComment] = useState('');
useEffect(() => {
setComment('');
}, [userId]);
return <textarea value={comment} onChange={e => setComment(e.target.value)} />;
}
// ✅ GOOD: Use key to reset component state
function ProfilePage({ userId }) {
// When userId changes, React unmounts the old EditProfile and mounts a new one
return <EditProfile userId={userId} key={userId} />;
}
function EditProfile({ userId }) {
const [comment, setComment] = useState('');
return <textarea value={comment} onChange={e => setComment(e.target.value)} />;
}
When useEffect IS Correct
Effects are appropriate for synchronizing your component with something outside of React:
| Use Case | Why It's Correct |
|---|---|
| Integrating a non-React widget (map, chart, video player) | Syncing React state → imperative DOM API |
| Setting up a WebSocket or EventSource connection | External system lifecycle tied to component |
Measuring DOM layout (getBoundingClientRect, ResizeObserver) |
Reading post-render DOM information |
| Managing focus or scroll position imperatively | Imperative DOM interaction |
| Connecting to hardware APIs (camera, geolocation) | External system with subscribe/cleanup |
document.title or other global side-effects |
Syncing React state → browser API |
Even for these, consider whether a library already handles it (e.g., react-intersection-observer, framer-motion).
Decision Flowchart
Ask yourself these questions before writing useEffect:
- Can I calculate this from existing props/state? → Derive it during render (or
useMemo). - Is this a response to a user action? → Put it in the event handler.
- Am I fetching data? → Use TanStack Query, SWR, or your framework's data primitive.
- Am I subscribing to an external store? → Use
useSyncExternalStoreor the library's hook. - Am I resetting state when a prop changes? → Use the
keyprop. - Am I syncing with an external system that React doesn't control? → ✅ This is a valid
useEffect.
Summary Checklist
- No
useEffectthat sets state derived from other state/props — calculate during render instead - No
useEffectthat runs logic in response to user events — use event handlers instead - No chains of
useEffects that trigger each other via state updates — consolidate into event handlers or derived values - No raw
useEffectfor data fetching — use TanStack Query, SWR, or framework data primitives - No
useEffect+setStatefor external store subscriptions — useuseSyncExternalStoreor library hooks - No
useEffectto reset state when props change — use thekeyprop - Every remaining
useEffectis syncing with a genuine external system (DOM API, WebSocket, third-party widget)