You're Probably Misusing useEffect
You're Probably Misusing useEffect
This isn't the "useEffect runs after render" tutorial. You know that. You've read the docs. You've built production apps.
And yet, under deadline pressure, you still reach for useEffect when you shouldn't. We all do. The pattern is so ingrained that it feels like the right tool even when it's actively making your code worse.
Let's fix that.
The Mental Model Problem
Here's how most developers think about useEffect:
┌─────────────────────────────────────────────────────────────────────┐
│ WRONG MENTAL MODEL │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ "useEffect is where I put code that needs to run │
│ after something changes" │
│ │
│ ┌─────────────┐ │
│ │ State │ │
│ │ changes │ │
│ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Component │ ──▶ │ useEffect │ ──▶ │ Do stuff │ │
│ │ re-renders │ │ fires │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ This model leads to: │
│ • Effect chains (effect triggers state, triggers effect...) │
│ • Unnecessary re-renders │
│ • Race conditions │
│ • Stale closure bugs │
│ • "Why does this run twice?" confusion │
│ │
└─────────────────────────────────────────────────────────────────────┘
Here's the mental model you need:
┌─────────────────────────────────────────────────────────────────────┐
│ CORRECT MENTAL MODEL │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ "useEffect is an escape hatch for synchronizing React │
│ with external systems" │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ REACT WORLD │ │
│ │ │ │
│ │ Props, State, Context, Derived Values, Event Handlers │ │
│ │ │ │
│ │ Everything here should be handled WITHOUT useEffect │ │
│ │ │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ useEffect │
│ │ (escape hatch) │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ EXTERNAL WORLD │ │
│ │ │ │
│ │ DOM APIs, Browser APIs, WebSockets, Timers, │ │
│ │ Third-party libraries, Analytics, Network requests │ │
│ │ │ │
│ │ This is the ONLY place useEffect belongs │ │
│ │ │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
The Three Questions
Before writing useEffect, ask:
┌─────────────────────────────────────────────────────────────────────┐
│ │
│ 1. Can this be calculated during render? │
│ ───────────────────────────────────── │
│ If yes → It's derived state. No effect needed. │
│ │
│ 2. Is this responding to a user action? │
│ ───────────────────────────────────── │
│ If yes → Put it in the event handler. No effect needed. │
│ │
│ 3. Am I synchronizing with something outside React? │
│ ─────────────────────────────────────────────── │
│ If yes → useEffect is appropriate. │
│ │
└─────────────────────────────────────────────────────────────────────┘
Let's break down each case.
Case 1: Derived State (No Effect Needed)
The most common useEffect mistake. You have some state, you want to compute something from it, so you reach for useEffect.
The Wrong Way
function ProductPage({ productId }: { productId: string }) {
const [product, setProduct] = useState<Product | null>(null);
const [discountedPrice, setDiscountedPrice] = useState<number>(0);
useEffect(() => {
fetchProduct(productId).then(setProduct);
}, [productId]);
// ❌ WRONG: Derived state in useEffect
useEffect(() => {
if (product) {
const discount = product.onSale ? 0.2 : 0;
setDiscountedPrice(product.price * (1 - discount));
}
}, [product]);
return <div>Price: ${discountedPrice}</div>;
}
What happens:
- Component renders with
discountedPrice = 0 - Product loads,
setProducttriggers re-render - Component renders again with stale
discountedPrice = 0 - Effect runs,
setDiscountedPricetriggers ANOTHER re-render - Component finally renders with correct price
Three renders for something that should be one.
The Right Way
function ProductPage({ productId }: { productId: string }) {
const [product, setProduct] = useState<Product | null>(null);
useEffect(() => {
fetchProduct(productId).then(setProduct);
}, [productId]);
// ✅ RIGHT: Calculated during render
const discountedPrice = product
? product.price * (1 - (product.onSale ? 0.2 : 0))
: 0;
return <div>Price: ${discountedPrice}</div>;
}
One render after data loads. The discounted price is always in sync with the product because it's derived, not synchronized.
"But the calculation is expensive!"
Use useMemo:
const discountedPrice = useMemo(() => {
if (!product) return 0;
// Expensive calculation
const baseDiscount = calculateComplexDiscount(product);
const seasonalModifier = getSeasonalModifier(new Date());
const loyaltyBonus = calculateLoyaltyDiscount(user);
return product.price * (1 - baseDiscount - seasonalModifier - loyaltyBonus);
}, [product, user]);
useMemo is for expensive calculations. useEffect is not.
Derived State Decision Tree
┌─────────────────────────────────────────────────────────────────────┐
│ │
│ "I need to update stateB when stateA changes" │
│ │ │
│ ▼ │
│ ┌───────────────────────┐ │
│ │ Is stateB computable │ │
│ │ from stateA alone? │ │
│ └───────────┬───────────┘ │
│ │ │
│ ┌────────┴────────┐ │
│ │ │ │
│ ▼ ▼ │
│ YES NO │
│ │ │ │
│ ▼ ▼ │
│ ┌───────────────┐ ┌────────────────────┐ │
│ │ It's derived │ │ Does updating stateB│ │
│ │ state. Delete │ │ depend on async │ │
│ │ stateB, just │ │ external data? │ │
│ │ calculate it. │ └─────────┬──────────┘ │
│ └───────────────┘ │ │
│ ┌─────────┴─────────┐ │
│ │ │ │
│ ▼ ▼ │
│ YES NO │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ useEffect may │ │ Can you update │ │
│ │ be appropriate │ │ both states in │ │
│ │ for the fetch │ │ the same event │ │
│ └─────────────────┘ │ handler? │ │
│ └────────┬────────┘ │
│ │ │
│ YES │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Do that instead │ │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
Case 2: Event Handling (No Effect Needed)
This is the sneaky one. Something happens because of a user action, but you put the response in useEffect because "the state changed."
The Wrong Way
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Result[]>([]);
// ❌ WRONG: Responding to user action via effect
useEffect(() => {
if (query.length > 2) {
searchAPI(query).then(setResults);
}
}, [query]);
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
);
}
Problems:
- Search runs on every keystroke (even with debounce, it's awkward)
- No way to distinguish "user is typing" from "component mounted with query in URL"
- Race conditions if user types fast
- Can't easily cancel previous request
The Right Way
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Result[]>([]);
// ✅ RIGHT: Response to user action in event handler
const handleSearch = async (searchQuery: string) => {
if (searchQuery.length > 2) {
const data = await searchAPI(searchQuery);
setResults(data);
}
};
return (
<input
value={query}
onChange={(e) => {
setQuery(e.target.value);
handleSearch(e.target.value);
}}
/>
);
}
Or with debounce:
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Result[]>([]);
const debouncedSearch = useMemo(
() => debounce(async (searchQuery: string) => {
if (searchQuery.length > 2) {
const data = await searchAPI(searchQuery);
setResults(data);
}
}, 300),
[]
);
// Cleanup debounce on unmount
useEffect(() => {
return () => debouncedSearch.cancel();
}, [debouncedSearch]);
return (
<input
value={query}
onChange={(e) => {
setQuery(e.target.value);
debouncedSearch(e.target.value);
}}
/>
);
}
The key insight: the search is a response to the user typing, not to the query state changing. The state change is a side effect of the user action, not the cause of the search.
The Form Submission Anti-Pattern
// ❌ WRONG: Effect chain for form submission
function ContactForm() {
const [formData, setFormData] = useState({ name: '', email: '' });
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
useEffect(() => {
if (isSubmitting) {
submitForm(formData)
.then(() => {
setIsSubmitted(true);
setIsSubmitting(false);
})
.catch(() => {
setIsSubmitting(false);
});
}
}, [isSubmitting, formData]);
const handleSubmit = () => {
setIsSubmitting(true); // Triggers the effect
};
// ...
}
This is a Rube Goldberg machine. Click → set flag → effect sees flag → do work → clear flag.
// ✅ RIGHT: Just do the thing in the event handler
function ContactForm() {
const [formData, setFormData] = useState({ name: '', email: '' });
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const handleSubmit = async () => {
setIsSubmitting(true);
try {
await submitForm(formData);
setIsSubmitted(true);
} finally {
setIsSubmitting(false);
}
};
// ...
}
User clicks submit → form submits. No indirection.
Case 3: Synchronization (useEffect Is Appropriate)
This is where useEffect belongs. You're connecting React to something outside React.
Legitimate useEffect Uses
// ✅ Syncing with browser APIs
useEffect(() => {
const handleResize = () => setWindowWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// ✅ Syncing with DOM elements (refs)
useEffect(() => {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.play();
} else {
videoRef.current.pause();
}
}
}, [isPlaying]);
// ✅ Syncing with third-party libraries
useEffect(() => {
const map = new MapLibrary(mapContainerRef.current);
map.setCenter(coordinates);
return () => map.destroy();
}, []);
useEffect(() => {
map?.setCenter(coordinates);
}, [coordinates]);
// ✅ WebSocket connections
useEffect(() => {
const ws = new WebSocket(url);
ws.onmessage = (event) => {
setMessages((prev) => [...prev, JSON.parse(event.data)]);
};
return () => ws.close();
}, [url]);
// ✅ Document title (external browser state)
useEffect(() => {
document.title = `${unreadCount} new messages`;
}, [unreadCount]);
// ✅ Analytics tracking
useEffect(() => {
analytics.pageView(pathname);
}, [pathname]);
The Pattern
┌─────────────────────────────────────────────────────────────────────┐
│ VALID useEffect PATTERN │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ useEffect(() => { │
│ // 1. Setup: Connect to external system │
│ const connection = connectToExternal(props.id); │
│ │
│ // 2. Sync: Make external system match React state │
│ connection.update(someState); │
│ │
│ // 3. Cleanup: Disconnect when deps change or unmount │
│ return () => connection.disconnect(); │
│ }, [props.id, someState]); │
│ │
│ ───────────────────────────────────────────────────────────────── │
│ │
│ Key characteristics: │
│ • You're talking to something outside React │
│ • That something doesn't automatically sync with React state │
│ • You need cleanup when the component unmounts or deps change │
│ │
└─────────────────────────────────────────────────────────────────────┘
The Data Fetching Question
"But what about fetching data? That's external!"
Yes, but there's nuance.
When useEffect Is Acceptable for Fetching
// ✅ Syncing data with component lifecycle
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchUser(userId)
.then((data) => {
if (!cancelled) {
setUser(data);
setLoading(false);
}
})
.catch((error) => {
if (!cancelled) {
setError(error);
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [userId]);
// ...
}
This is synchronization: "whenever userId changes, sync the user data."
When You Should Use Something Else
// ✅ React Query / SWR (better for most cases)
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
// ...
}
Why is this better?
- Handles race conditions
- Caching
- Background refetching
- Deduplication
- Loading/error states built in
- No cleanup boilerplate
When to Use Event Handlers for Fetching
// ✅ Fetch triggered by user action
function Dashboard() {
const [report, setReport] = useState<Report | null>(null);
const generateReport = async () => {
const data = await fetchReport({ startDate, endDate });
setReport(data);
};
return (
<button onClick={generateReport}>
Generate Report
</button>
);
}
User clicks → fetch happens. No effect needed.
The Fetching Decision Tree
┌─────────────────────────────────────────────────────────────────────┐
│ │
│ "I need to fetch data" │
│ │ │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ Is it in response to │ │
│ │ a user action? │ │
│ └───────────┬─────────────┘ │
│ │ │
│ ┌────────┴────────┐ │
│ │ │ │
│ ▼ ▼ │
│ YES NO │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────┐ ┌─────────────────────────┐ │
│ │ Event │ │ Should component always │ │
│ │ handler │ │ show current data for │ │
│ └─────────┘ │ its props/state? │ │
│ └───────────┬─────────────┘ │
│ │ │
│ ┌────────┴────────┐ │
│ │ │ │
│ ▼ ▼ │
│ YES NO (one-time load) │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────────┐ │
│ │ React Query │ │ useEffect with │ │
│ │ or SWR │ │ empty deps [] │ │
│ │ (preferred) │ │ or event handler│ │
│ │ │ │ on mount │ │
│ │ OR useEffect│ └─────────────────┘ │
│ │ with deps │ │
│ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
Effect Chains: The Code Smell
If you have effects that trigger other effects, something is wrong.
// ❌ Effect chain - a code smell
function CheckoutPage() {
const [cart, setCart] = useState<Cart | null>(null);
const [shipping, setShipping] = useState<Shipping | null>(null);
const [tax, setTax] = useState<number>(0);
const [total, setTotal] = useState<number>(0);
// Effect 1: Load cart
useEffect(() => {
fetchCart().then(setCart);
}, []);
// Effect 2: When cart changes, calculate shipping
useEffect(() => {
if (cart) {
calculateShipping(cart).then(setShipping);
}
}, [cart]);
// Effect 3: When shipping changes, calculate tax
useEffect(() => {
if (cart && shipping) {
setTax(calculateTax(cart.subtotal + shipping.cost));
}
}, [cart, shipping]);
// Effect 4: When tax changes, calculate total
useEffect(() => {
if (cart && shipping) {
setTotal(cart.subtotal + shipping.cost + tax);
}
}, [cart, shipping, tax]);
// 4 effects, 4 separate render cycles, race condition bugs waiting to happen
}
Fix it:
// ✅ Proper data flow
function CheckoutPage() {
const [cart, setCart] = useState<Cart | null>(null);
const [shipping, setShipping] = useState<Shipping | null>(null);
// Only fetch effects (external synchronization)
useEffect(() => {
fetchCart().then(setCart);
}, []);
useEffect(() => {
if (cart) {
calculateShipping(cart).then(setShipping);
}
}, [cart]);
// Derived state - calculated during render
const tax = cart && shipping
? calculateTax(cart.subtotal + shipping.cost)
: 0;
const total = cart && shipping
? cart.subtotal + shipping.cost + tax
: 0;
// 2 effects for external data, derived values computed synchronously
}
Or with React Query:
// ✅ Even better with React Query
function CheckoutPage() {
const { data: cart } = useQuery({
queryKey: ['cart'],
queryFn: fetchCart
});
const { data: shipping } = useQuery({
queryKey: ['shipping', cart?.id],
queryFn: () => calculateShipping(cart!),
enabled: !!cart
});
const tax = cart && shipping
? calculateTax(cart.subtotal + shipping.cost)
: 0;
const total = cart && shipping
? cart.subtotal + shipping.cost + tax
: 0;
}
The "Reset State When Props Change" Anti-Pattern
// ❌ WRONG: Resetting state via effect
function EditUserForm({ userId }: { userId: string }) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
useEffect(() => {
// Reset form when userId changes
setName('');
setEmail('');
}, [userId]);
// ...
}
This causes: render with stale state → effect runs → re-render with reset state.
The Right Way: Key
// ✅ RIGHT: Use key to reset component
function App() {
const [userId, setUserId] = useState('1');
return (
<EditUserForm key={userId} userId={userId} />
);
}
function EditUserForm({ userId }: { userId: string }) {
// Fresh state every time userId changes because component remounts
const [name, setName] = useState('');
const [email, setEmail] = useState('');
// ...
}
When the key changes, React unmounts the old component and mounts a new one. Fresh state, no effect needed.
Common Patterns Rewritten
❌ Setting state based on props
// Wrong
function List({ items, filter }: { items: Item[]; filter: string }) {
const [filteredItems, setFilteredItems] = useState<Item[]>([]);
useEffect(() => {
setFilteredItems(items.filter(item => item.name.includes(filter)));
}, [items, filter]);
return <ul>{filteredItems.map(...)}</ul>;
}
// Right
function List({ items, filter }: { items: Item[]; filter: string }) {
const filteredItems = items.filter(item => item.name.includes(filter));
// Or useMemo if expensive:
// const filteredItems = useMemo(() => items.filter(...), [items, filter]);
return <ul>{filteredItems.map(...)}</ul>;
}
❌ Transforming data from parent
// Wrong
function Chart({ rawData }: { rawData: RawData }) {
const [chartData, setChartData] = useState<ChartData | null>(null);
useEffect(() => {
setChartData(transformForChart(rawData));
}, [rawData]);
return <ChartLibrary data={chartData} />;
}
// Right
function Chart({ rawData }: { rawData: RawData }) {
const chartData = useMemo(() => transformForChart(rawData), [rawData]);
return <ChartLibrary data={chartData} />;
}
❌ Notifying parent of state changes
// Wrong
function Counter({ onChange }: { onChange: (count: number) => void }) {
const [count, setCount] = useState(0);
useEffect(() => {
onChange(count);
}, [count, onChange]);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
// Right
function Counter({ onChange }: { onChange: (count: number) => void }) {
const [count, setCount] = useState(0);
const increment = () => {
const newCount = count + 1;
setCount(newCount);
onChange(newCount);
};
return <button onClick={increment}>{count}</button>;
}
❌ Initializing state from async source (once)
// Awkward
function Profile() {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetchCurrentUser().then(setUser);
}, []);
if (!user) return <Loading />;
return <ProfileView user={user} />;
}
// Better (with React Query)
function Profile() {
const { data: user, isLoading } = useQuery({
queryKey: ['currentUser'],
queryFn: fetchCurrentUser
});
if (isLoading) return <Loading />;
return <ProfileView user={user!} />;
}
// Or with Suspense (React 18+)
function Profile() {
const user = use(fetchCurrentUser()); // with a resource/cache
return <ProfileView user={user} />;
}
The Strict Mode Test
React 18's Strict Mode runs effects twice in development. If your effect breaks, it's a sign the effect is wrong.
// ❌ Breaks in Strict Mode (runs twice)
useEffect(() => {
const subscription = api.subscribe(handleUpdate);
// No cleanup - subscription leaks
}, []);
// ✅ Works in Strict Mode
useEffect(() => {
const subscription = api.subscribe(handleUpdate);
return () => subscription.unsubscribe(); // Cleanup
}, []);
Effects should be idempotent. Setup-cleanup-setup should leave you in the same state as just setup.
The Checklist
Before writing useEffect:
┌─────────────────────────────────────────────────────────────────────┐
│ useEffect CHECKLIST │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ □ Can this be computed during render? │
│ → If yes, use a variable or useMemo │
│ │
│ □ Is this responding to a user event? │
│ → If yes, put it in the event handler │
│ │
│ □ Am I setting state based on other state? │
│ → If yes, derive it or set both in the same handler │
│ │
│ □ Am I setting state based on props? │
│ → If yes, use key to reset or derive the value │
│ │
│ □ Does this effect trigger another effect? │
│ → If yes, restructure to avoid the chain │
│ │
│ □ Am I synchronizing with an EXTERNAL system? │
│ → If yes, useEffect is appropriate │
│ │
│ □ Does my effect have proper cleanup? │
│ → If it sets up subscriptions/listeners, it needs cleanup │
│ │
│ □ Does my effect work correctly if run twice? │
│ → Test with Strict Mode │
│ │
└─────────────────────────────────────────────────────────────────────┘
The Refactoring Exercise
Next time you open a file with multiple useEffects, try this:
- Identify derived state: Any
setXin an effect whereXcould be calculated from other state/props - Identify event responses: Any effect that runs because of a user action
- Identify true synchronization: What's actually connecting to external systems
Most codebases have 2-3x more effects than they need. The code becomes simpler, faster, and less buggy when you remove them.
Closing Thoughts
useEffect is a tool of last resort, not a general-purpose "do stuff when things change" mechanism. It exists because React can't automatically sync with browser APIs, third-party libraries, and network connections.
When you find yourself reaching for useEffect, pause. Ask the three questions. Most of the time, you don't need it.
The best React code has very few effects. It's mostly:
- State that changes in event handlers
- Values derived during render
- A handful of effects for true external synchronization
Get comfortable with that model, and you'll write better React under pressure, not just when you have time to think about it.
What did you think?