😵 The Problem
You're building a React component.
You need to fetch some data when the component appears:
function Users() {
// fetch users here
}
Easy, right?
So you might try:
function Users() {
fetch("/api/users");
return <h1>Users</h1>;
}
Looks innocent.
But there's a problem.
Every time the component renders…
👉 the fetch() runs again.
And if your component re-renders because of state changes?
Yep.
Another request.
And another.
And another. 💀
This is exactly the kind of problem useEffect is designed to solve.
💡 The Solution: useEffect
useEffect lets you run code after React has rendered your component.
For example:
import { useEffect } from "react";
function Users() {
useEffect(() => {
fetch("/api/users");
}, []);
return <h1>Users</h1>;
}
The important part is:
useEffect(() => {
// side effect
}, []);
You're basically telling React:
"After rendering this component, run this side effect."
That side effect could be:
- fetching data
- changing the document title
- subscribing to something
- setting up timers
- interacting with browser APIs
- connecting to external systems
🧠 First, What Is a "Side Effect"?
This term sounds more complicated than it really is.
Rendering should ideally be predictable.
For example:
function User({ name }) {
return <h2>{name}</h2>;
}
That's just UI calculation.
But this:
document.title = "Dashboard";
changes something outside the React component.
Or:
fetch("/api/users");
talks to an external system.
Those are side effects.
A useful mental model:
Rendering calculates UI. Effects synchronize with things outside React.
That distinction becomes really important as your app grows.
🔥 The Dependency Array Is the Important Part
You'll usually see three versions.
1. No Dependency Array
useEffect(() => {
console.log("Effect ran");
});
This runs after every render.
Sometimes that's intentional.
Most of the time, though, you should stop and ask why.
2. Empty Dependency Array
useEffect(() => {
console.log("Effect ran");
}, []);
This tells React that the effect doesn't depend on changing reactive values.
It runs after the initial mount in normal usage.
But there's an important development detail: with React Strict Mode, React may intentionally run setup and cleanup an extra time in development to help detect unsafe effects.
So don't build logic that assumes an effect literally runs only once forever.
3. Dependencies
useEffect(() => {
console.log("User changed:", userId);
}, [userId]);
Now the effect runs when userId changes.
This is where useEffect becomes genuinely useful.
🎯 Real Example: Fetching Data
Let's say your page loads products based on a category.
import { useEffect, useState } from "react";
function Products({ category }) {
const [products, setProducts] = useState([]);
useEffect(() => {
async function loadProducts() {
const response = await fetch(
`/api/products?category=${category}`
);
const data = await response.json();
setProducts(data);
}
loadProducts();
}, [category]);
return (
<div>
{products.map(product => (
<p key={product.id}>
{product.name}
</p>
))}
</div>
);
}
Now the relationship is clear:
category changes
↓
useEffect runs
↓
fetch new products
↓
setProducts()
↓
component renders with new data
That's a much better mental model than:
"useEffect is where I put code that should run."
Instead ask:
"What external system am I synchronizing with, and what values should cause that synchronization to happen?"
🔥 Real Example: Updating the Page Title
You don't need an API to understand useEffect.
function Profile({ name }) {
useEffect(() => {
document.title = `${name}'s Profile`;
}, [name]);
return <h1>{name}</h1>;
}
Whenever name changes:
name changes
↓
React renders
↓
effect runs
↓
document.title updates
Simple.
And this is a good example of a genuine effect because document.title lives outside React.
⏱️ Real Example: Timers
Imagine a countdown.
You could create an interval inside an effect:
import { useEffect, useState } from "react";
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const intervalId = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
return () => {
clearInterval(intervalId);
};
}, []);
return <h2>{seconds}s</h2>;
}
Notice the return.
That's the cleanup function.
🧹 Cleanup: The Part People Forget
Some effects create resources that need to be removed.
Examples:
- intervals
- event listeners
- subscriptions
- WebSocket connections
If you create one:
const intervalId = setInterval(...);
you should clean it up:
return () => {
clearInterval(intervalId);
};
The mental model is:
Effect starts something
↓
Component stays active
↓
Component unmounts / effect reruns
↓
Cleanup removes the old thing
Without cleanup, you can end up with memory leaks, duplicate listeners, duplicate connections, or stale behavior.
💡 Real Developer Insight
Here's where I see developers misuse `useEffect the most:
They use it for things that don't need an effect.
For example:
const fullName = `${firstName} ${lastName}`;
You don't need:
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
That's unnecessary state + unnecessary effect.
Just calculate it:
const fullName = `${firstName} ${lastName}`;
Same result.
Less code.
Less synchronization.
Less chance of bugs.
Another common example:
const filteredUsers = users.filter(
user => user.active
);
You usually don't need an effect to calculate this either.
useEffect isn't a general-purpose "run this code later" hook.
Use it when you're synchronizing React with something outside React.
That's the mindset that makes useEffect much easier to use correctly.
⚠️ Common Developer Mistakes
1. Forgetting Dependencies
useEffect(() => {
fetch(`/api/users/${userId}`);
}, []);
If userId can change, the effect is now using stale data.
You likely need:
useEffect(() => {
fetch(`/api/users/${userId}`);
}, [userId]);2. Creating Effects for Derived Values
Don't do this:
useEffect(() => {
setTotal(price * quantity);
}, [price, quantity]);
If total can simply be calculated:
const total = price * quantity;
That's cleaner.
3. Forgetting Cleanup
Bad:
useEffect(() => {
window.addEventListener("resize", handleResize);
}, []);
Better:
useEffect(() => {
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);4. Ignoring Race Conditions in Data Fetching
Imagine:
Request A → slow
Request B → fast
B returns first.
Then A finally returns and overwrites the newer data.
For real applications, async effects may need cancellation or an "ignore stale result" strategy.
For example:
useEffect(() => {
const controller = new AbortController();
async function loadUser() {
try {
const response = await fetch(
`/api/users/${userId}`,
{ signal: controller.signal }
);
const data = await response.json();
setUser(data);
} catch (error) {
if (error.name !== "AbortError") {
console.error(error);
}
}
}
loadUser();
return () => {
controller.abort();
};
}, [userId]);
That's much closer to how you'd approach data fetching in a production application.
🧠 One More Important Thing: Don't Make the Effect Function async
You might be tempted to write:
useEffect(async () => {
// ...
}, []);
Don't.
The effect callback is expected to return either nothing or a cleanup function, not a Promise.
Instead:
useEffect(() => {
async function loadData() {
// async work
}
loadData();
}, []);
This small distinction saves you from some very confusing behavior.
🚀 Best Practice Summary
🎯 Conclusion
useEffect is one of those React features that looks simple:
useEffect(() => {
// something
}, []);
Then you build a real application…
…and suddenly you're staring at five effects wondering:
"Why is this running again?" 😅
The trick isn't memorizing dependency-array rules.
Understand the reason the effect exists.
Ask:
"What external thing am I synchronizing with?"
If the answer is:
- API
- browser API
- timer
- event listener
- subscription
- external system
then useEffect may be exactly what you need.
But if you're just calculating a value from existing props or state…
You probably don't need an effect.
Less effects usually means less complexity. 🚀
0 Comments