React useEffect – Side Effects & Component Lifecycle
After learning Lifting State Up on Day 68, we now understand how components can share and coordinate state.
But there is another important question:
What happens when our component needs to do something outside of rendering UI?
For example:
Fetch data from an API
Start a timer
Listen for browser events
Update the document title
Subscribe to something
Read or interact with browser APIs
Perform an action when state changes
These are called side effects, and React provides the useEffect Hook to handle them.
🔹 What Is a Side Effect?
A side effect is an operation that happens outside the normal process of calculating and rendering JSX.
For example:
function App() {
document.title = "My React App";
return <h1>Hello React</h1>;
}
This changes something outside the React component itself.
That's a side effect.
Other examples:
API requests
Timers
Console logging
DOM manipulation
Event listeners
Subscriptions
Browser storage
WebSocket connections
React rendering should ideally remain pure.
That means:
Props + State
↓
Render
↓
JSX
Side effects happen separately:
Render
↓
React commits UI
↓
useEffect runs
↓
External operation
🔹 What Is useEffect?
useEffect is a React Hook used to synchronize a component with an external system.
Basic syntax:
import { useEffect } from "react";
useEffect(() => {
// Side effect
});
For example:
import { useEffect } from "react";
function App() {
useEffect(() => {
console.log("Effect executed");
});
return <h1>Hello React</h1>;
}
The effect runs after React renders the component.
🔹 The Dependency Array
The dependency array determines when an effect should run.
There are three important patterns.
1️⃣ No Dependency Array
useEffect(() => {
console.log("Effect executed");
});
This runs after every render.
Example:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log("Component rendered");
});
return (
<>
<h1>{count}</h1>
<button onClick={() => setCount(count + 1)}>
Increase
</button>
</>
);
}
Every state update causes another render.
Therefore:
Initial render → Effect
count update → Render → Effect
count update → Render → Effect
2️⃣ Empty Dependency Array
useEffect(() => {
console.log("Effect executed");
}, []);
The empty array means:
Run this effect when the component is mounted.
Example:
function App() {
useEffect(() => {
console.log("Component mounted");
}, []);
return <h1>Hello</h1>;
}
This is commonly used for things such as:
Initial API request
Initial setup
Browser event listeners
Initial subscriptions
3️⃣ Dependencies
You can provide values that the effect depends on.
useEffect(() => {
console.log("Count changed");
}, [count]);
Now the effect runs when count changes.
Example:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log("Count:", count);
}, [count]);
return (
<>
<h1>{count}</h1>
<button onClick={() => setCount(count + 1)}>
Increase
</button>
</>
);
}
The dependency array tells React:
"Run this effect again whenever one of these values changes."
🔹 Understanding the Dependency Array
Think of it like this:
| Dependency | Behavior |
|---|---|
| No array | After every render |
[] |
After initial mount |
[count] |
When count changes |
[count, name] |
When either changes |
Example:
useEffect(() => {
console.log("Effect");
}, [count, name]);
The effect depends on both count and name.
🔹 useEffect and State
One of the most common patterns is reacting to state changes.
const [name, setName] = useState("");
useEffect(() => {
console.log(`Hello ${name}`);
}, [name]);
Whenever name changes:
name changes
↓
React re-renders
↓
Effect checks dependencies
↓
name changed
↓
Effect runs
🔹 Updating the Browser Tab Title
A simple and useful example:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
return (
<>
<h1>{count}</h1>
<button onClick={() => setCount(count + 1)}>
Increase
</button>
</>
);
}
Now the browser tab changes as the counter changes.
This is a perfect example of a side effect because:
React State
↓
useEffect
↓
Browser document title
🔹 Fetching Data With useEffect
One of the most common real-world use cases is fetching data.
Example:
import { useEffect, useState } from "react";
function Users() {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch("https://jsonplaceholder.typicode.com/users")
.then((response) => response.json())
.then((data) => {
setUsers(data);
});
}, []);
return (
<div>
{users.map((user) => (
<p key={user.id}>{user.name}</p>
))}
</div>
);
}
The flow is:
Component mounts
↓
useEffect runs
↓
API request
↓
Data received
↓
setUsers()
↓
Component re-renders
↓
Users displayed
This pattern is extremely important for frontend development.
🔹 Loading State
Real applications need to show users what's happening.
function Users() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("https://jsonplaceholder.typicode.com/users")
.then((response) => response.json())
.then((data) => {
setUsers(data);
setLoading(false);
});
}, []);
if (loading) {
return <h2>Loading...</h2>;
}
return (
<div>
{users.map((user) => (
<p key={user.id}>{user.name}</p>
))}
</div>
);
}
Now the UI has two states:
Loading
↓
API response
↓
Users displayed
🔹 Error Handling
A production application also needs an error state.
function Users() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
fetch("https://jsonplaceholder.typicode.com/users")
.then((response) => {
if (!response.ok) {
throw new Error("Failed to fetch users");
}
return response.json();
})
.then((data) => {
setUsers(data);
})
.catch((error) => {
setError(error.message);
})
.finally(() => {
setLoading(false);
});
}, []);
if (loading) {
return <h2>Loading...</h2>;
}
if (error) {
return <h2>{error}</h2>;
}
return (
<div>
{users.map((user) => (
<p key={user.id}>{user.name}</p>
))}
</div>
);
}
Now we have:
Loading
↓
Success → Display data
OR
Error → Display error message
This is a common pattern in real React applications.
🔹 Cleanup Functions
Some effects create resources that need to be cleaned up.
Examples:
Timers
Event listeners
Subscriptions
WebSocket connections
useEffect allows us to return a cleanup function.
useEffect(() => {
// Setup
return () => {
// Cleanup
};
}, []);
🔹 Timer Example
useEffect(() => {
const timer = setInterval(() => {
console.log("Running...");
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
The effect creates a timer.
The cleanup removes it.
Component mounted
↓
Timer starts
↓
Component unmounts
↓
Cleanup runs
↓
Timer stops
Without cleanup, resources can continue running unnecessarily.
🔹 Event Listener Example
Suppose we want to listen for window resize events.
useEffect(() => {
function handleResize() {
console.log(window.innerWidth);
}
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
Notice that we remove the same function we added.
This is important.
🔹 Component Lifecycle
Although modern React emphasizes effects as synchronization with external systems rather than lifecycle methods, it's useful to understand the traditional lifecycle idea.
A component goes through stages:
Mount
↓
Update
↓
Unmount
Mount
Component appears on the screen.
Update
State or props change.
Unmount
Component is removed from the UI.
useEffect can participate in these transitions through its setup and cleanup behavior.
🔹 Cleanup Before Re-running an Effect
This is an important concept.
Suppose:
useEffect(() => {
console.log("Effect");
return () => {
console.log("Cleanup");
};
}, [count]);
When count changes, React conceptually does:
Previous effect
↓
Cleanup
↓
New effect
This becomes especially important for:
Subscriptions
Event listeners
Timers
Connections
🔹 useEffect With Props
Effects can depend on props as well.
function UserProfile({ userId }) {
useEffect(() => {
console.log("Fetching user:", userId);
}, [userId]);
return <h1>User Profile</h1>;
}
Whenever userId changes, the effect runs again.
This is useful when navigating between users:
/user/1
↓
Fetch user 1
/user/2
↓
Fetch user 2
🔹 Don't Use useEffect for Everything
This is one of the most important lessons.
You don't need an effect just because something changes.
For example, this is unnecessary:
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
Instead:
const fullName = `${firstName} ${lastName}`;
Why?
Because fullName is derived data.
It can be calculated directly during rendering.
🔹 Derived State vs Side Effect
This distinction is extremely important.
Derived value
const fullName = `${firstName} ${lastName}`;
No effect required.
External synchronization
useEffect(() => {
document.title = fullName;
}, [fullName]);
Effect makes sense because we're interacting with something outside React.
Think:
If you can calculate it during render, don't automatically reach for
useEffect.
🔹 Common Mistake: Infinite Loops
Consider:
useEffect(() => {
setCount(count + 1);
}, [count]);
What's happening?
count changes
↓
effect runs
↓
setCount()
↓
count changes
↓
effect runs
↓
setCount()
↓
...
This creates an infinite update loop.
Always ask:
"Does this effect update a dependency that causes the effect to run again?"
🔹 Another Common Mistake: Missing Dependencies
Example:
useEffect(() => {
console.log(user);
}, []);
If the effect uses a reactive value such as user, but you intentionally leave it out of the dependency list, you can end up with stale values.
A better approach is generally:
useEffect(() => {
console.log(user);
}, [user]);
Modern React tooling can help detect dependency mistakes.
🔹 useEffect and async
A common mistake is:
useEffect(async () => {
// ❌ Don't do this
}, []);
Instead, define an async function inside the effect:
useEffect(() => {
async function fetchUsers() {
const response = await fetch(
"https://jsonplaceholder.typicode.com/users"
);
const data = await response.json();
setUsers(data);
}
fetchUsers();
}, []);
This keeps the effect callback's return value available for cleanup.
🔹 A Realistic Data Fetching Pattern
Here's a more complete example:
import { useEffect, useState } from "react";
function Users() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
async function fetchUsers() {
try {
setLoading(true);
const response = await fetch(
"https://jsonplaceholder.typicode.com/users"
);
if (!response.ok) {
throw new Error("Unable to fetch users");
}
const data = await response.json();
setUsers(data);
} catch (error) {
setError(error.message);
} finally {
setLoading(false);
}
}
fetchUsers();
}, []);
if (loading) {
return <h2>Loading users...</h2>;
}
if (error) {
return <h2>Error: {error}</h2>;
}
return (
<div>
<h1>Users</h1>
{users.map((user) => (
<div key={user.id}>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
))}
</div>
);
}
export default Users;
This pattern combines several concepts you've already learned:
useState
+
useEffect
+
API
+
Conditional Rendering
+
Lists
+
Keys
That's exactly how React concepts begin coming together in real applications.
🔹 useEffect Mental Model
Instead of memorizing syntax, remember this:
React renders UI
↓
Something outside React needs synchronization
↓
useEffect
↓
Perform external operation
↓
Cleanup when necessary
Examples:
API request
Browser title
Timer
Event listener
Subscription
WebSocket
Storage interaction
🔹 Best Practices
✅ 1. Use effects for external synchronization
Don't use useEffect simply because you can.
✅ 2. Keep dependency arrays accurate
useEffect(() => {
console.log(name);
}, [name]);
✅ 3. Clean up resources
return () => {
clearInterval(timer);
};
✅ 4. Avoid unnecessary derived state
Instead of:
useEffect(() => {
setTotal(price * quantity);
}, [price, quantity]);
Prefer:
const total = price * quantity;
✅ 5. Handle API states
Real applications should consider:
Loading
Success
Error
Empty
✅ 6. Avoid infinite effect loops
Be careful when an effect updates one of its own dependencies.
✅ 7. Keep effects focused
One effect should ideally represent one synchronization concern.
🔹 useEffect Cheat Sheet
// Every render
useEffect(() => {
// ...
});
// After initial mount
useEffect(() => {
// ...
}, []);
// When count changes
useEffect(() => {
// ...
}, [count]);
// With cleanup
useEffect(() => {
const timer = setInterval(() => {
console.log("Running");
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
My Biggest Takeaway
Today I learned that useEffect isn't simply a "run some code after rendering" Hook.
Its real purpose is to synchronize a React component with something outside of React's rendering system.
The most important mental model for me is:
Render describes the UI. Effects synchronize the UI with external systems.
I also learned how dependency arrays control when effects run, why cleanup functions matter, how API requests fit into useEffect, and why unnecessary effects can make React applications harder to understand.
After learning state, events, forms, lists, conditional rendering, lifting state, and now effects, React is starting to feel much more like a complete application-building tool rather than just a UI library.
