React useEffect Explained — Side Effects in Function Components
In this tutorial, you will learn about React useEffect Explained. We cover key concepts, practical examples, and best practices to help you master this topic.
React useEffect lets you perform side effects in function components: fetching data, subscribing to events, manipulating the DOM, and synchronizing with external systems.
What You'll Learn
- What side effects are and why they need special handling
- How useEffect runs after render
- How dependency arrays control when effects run
- How to clean up effects to prevent memory leaks
- Common useEffect patterns
Why It Matters
Components should be pure functions of props and state. Side effects break that purity. useEffect provides a dedicated place for side effects, keeping the render phase pure and predictable.
Real-World Use
Durga Antivirus Pro uses effects to start file system watchers, subscribe to threat detection events via Websocket, update the scan progress bar, and synchronize preferences with localStorage.
flowchart LR
A[Component Renders] --> B[Browser Paints DOM]
B --> C[useEffect Runs]
C --> D[Side Effect: API Call]
C --> E[Side Effect: Subscribe]
C --> F[Side Effect: DOM Update]
D --> G[Set State] --> A
style A fill:#3b82f6,color:#fff
Basic useEffect
The most common side effect is fetching data:
import { useState, useEffect } from "react";
function ProductList({ category }) {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
console.log("Fetching products for category:", category);
fetch(`/api/products?category=${category}`)
.then(res => res.json())
.then(data => {
setProducts(data);
setLoading(false);
})
.catch(err => {
console.error("Failed to fetch products:", err);
setLoading(false);
});
}, [category]);
if (loading) return <div>Loading products...</div>;
return (
<ul>
{products.map(product => (
<li key={product.id}>{product.name} - ${product.price}</li>
))}
</ul>
);
}
Expected output: Products load when the component mounts. Changing the category re-fetches products.
The effect runs after every render where category changed. React guarantees the DOM has been updated before effects run, so you can safely read from or write to the DOM.
Effect with Cleanup
Cleanup functions prevent memory leaks:
import { useState, useEffect } from "react";
function MouseTracker() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMouseMove = (event) => {
setPosition({ x: event.clientX, y: event.clientY });
};
console.log("Adding mouse listener");
window.addEventListener("mousemove", handleMouseMove);
return () => {
console.log("Removing mouse listener");
window.removeEventListener("mousemove", handleMouseMove);
};
}, []);
return (
<div>
<p>Mouse position: {position.x}, {position.y}</p>
</div>
);
}
function MouseToggle() {
const [visible, setVisible] = useState(true);
return (
<div>
<button onClick={() => setVisible(!visible)}>
{visible ? "Hide" : "Show"} Tracker
</button>
{visible && <MouseTracker />}
</div>
);
}
Expected output: When visible, mouse position updates. When hidden, the event listener is removed. Showing it again adds a fresh listener.
The cleanup function runs when the component unmounts or before the effect re-runs. Every effect that creates a subscription, timer, or listener must return a cleanup.
Multiple Effects
Separate unrelated concerns into different useEffect calls:
function Dashboard({ userId }) {
const [user, setUser] = useState(null);
const [notifications, setNotifications] = useState([]);
const [theme, setTheme] = useState(() => {
return localStorage.getItem("theme") || "light";
});
// Effect 1: Fetch user data
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(setUser);
}, [userId]);
// Effect 2: Subscribe to notifications
useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/notifications?userId=${userId}`);
ws.onmessage = (event) => {
setNotifications(prev => [...prev, JSON.parse(event.data)]);
};
return () => ws.close();
}, [userId]);
// Effect 3: Sync theme to localStorage
useEffect(() => {
localStorage.setItem("theme", theme);
document.body.className = theme;
}, [theme]);
// Effect 4: Set up keyboard shortcut
useEffect(() => {
const handler = (e) => {
if (e.key === "Escape") setNotifications([]);
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
return <div>...</div>;
}
Expected output: Four independent effects, each handling a specific side effect. They do not interfere with each other.
Each useEffect handles one concern. This makes the code easier to reason about and the dependency arrays more precise.
Effect with DOM Measurements
Effects can read from the DOM after render:
import { useState, useEffect, useRef } from "react";
function TextExpander({ children, maxHeight = 100 }) {
const [expanded, setExpanded] = useState(false);
const [needsExpand, setNeedsExpand] = useState(false);
const contentRef = useRef(null);
useEffect(() => {
if (contentRef.current) {
const scrollHeight = contentRef.current.scrollHeight;
setNeedsExpand(scrollHeight > maxHeight);
}
}, [maxHeight, children]);
return (
<div>
<div
ref={contentRef}
style={{
maxHeight: expanded ? "none" : `${maxHeight}px`,
overflow: "hidden",
transition: "max-height 0.3s ease"
}}
>
{children}
</div>
{needsExpand && (
<button onClick={() => setExpanded(!expanded)}>
{expanded ? "Show Less" : "Show More"}
</button>
)}
</div>
);
}
Expected output: If content exceeds maxHeight, a "Show More" button appears. Clicking it expands the content.
Reading the DOM is a side effect that must happen after the browser has laid out the DOM. useEffect guarantees the paint is complete before it runs.
Effect with Debounce
Debounce effect execution to avoid expensive operations:
import { useState, useEffect } from "react";
function SearchAutocomplete() {
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState([]);
useEffect(() => {
if (!query || query.length < 2) {
setSuggestions([]);
return;
}
const timer = setTimeout(async () => {
const response = await fetch(`/api/search?s=${query}`);
const data = await response.json();
setSuggestions(data);
}, 300);
return () => clearTimeout(timer);
}, [query]);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
{suggestions.length > 0 && (
<ul>
{suggestions.map(s => <li key={s.id}>{s.title}</li>)}
</ul>
)}
</div>
);
}
Expected output: The API call is debounced by 300ms. If the user types within 300ms, the previous timer is cleared and a new one starts.
The cleanup function clears the previous timer, effectively debouncing the effect. This pattern prevents excessive API calls during rapid typing.
Common Mistakes
Missing dependencies — Using variables inside an effect that are not listed in the dependency array causes stale closures.
Unnecessary dependencies — Including functions or objects in dependencies can cause infinite loops. Use
useCallbackfor functions.Forgetting cleanup — Timers, subscriptions, listeners, and fetch requests without cleanup cause memory leaks and warnings.
Running effects synchronously — Effects are async by nature. Do not use them for sync operations that belong in the render phase.
Fetching without handling race conditions — If the effect runs again before a previous fetch completes, state may update with stale data. Use cleanup to abort.
Practice Questions
When does useEffect run? After every completed render, but only if one of the dependencies has changed since the last render.
What does the dependency array control? It tells React when to re-run the effect. Only run when specific values change.
When does the cleanup function execute? Before the component unmounts and before each re-execution of the effect.
How do you make useEffect run only once? Pass an empty dependency array
[]. The effect runs only after the initial render.Why should effects be about a single concern? Multiple concerns in one effect make dependencies harder to manage and effects less reusable.
Challenge
Build a InfiniteScroll component that loads more data as the user scrolls. Use useEffect to attach a scroll listener with IntersectionObserver. Clean up the Observer on unmount. Debounce the scroll handler to avoid excessive checks.
FAQ
Mini Project
Build a RealtimeDashboard with multiple data sources. Use separate effects for: fetching initial data, subscribing to a WebSocket for live updates, syncing state to localStorage, and resizing charts on window resize. Each effect should have proper cleanup. Simulate the WebSocket with setInterval that generates random data points. Display connection status that reacts to effects running.
What's Next
Continue with refs and context:
React Refs, React Context, React Hooks
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro