Technical Blog - Loading...
React.js, Next.js & MERN Stack Tutorials
React.js, Next.js & MERN Stack Tutorials
React
March 1, 2025
SHARE

The useEffect hook is one of React's most powerful features, yet it's also one of the most misunderstood. Having worked on several complex React applications, I've witnessed firsthand how improper use of useEffect can lead to subtle bugs, memory leaks, and performance issues that are difficult to diagnose.
In this comprehensive guide, I'll share the best practices, common pitfalls, and optimization techniques I've learned from building production-grade React applications.

Before diving into specific patterns, it's crucial to understand what useEffect actually does. Many React developers struggle with effects because they approach them with an incorrect mental model.
The key insight: useEffect is not just for "side effects" in the traditional programming sense. It's for synchronizing your component with external systems.
That's it. That's the whole purpose of useEffect. When you think about effects in terms of synchronization, many of the common patterns and pitfalls become clearer.
jsx
function ProfilePage({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
// This effect synchronizes the component with an external system (API)
async function fetchUser() {
const response = await fetch(/api/users/${userId});
const userData = await response.json();
setUser(userData);
}
fetchUser();
}, [userId]); // Re-synchronize when userId changes
// ...
}
With this mental model in place, let's explore specific DOs and DON'Ts.
Always include a dependency array, even if it's empty. This is not just about optimization—it's about explicitly stating your synchronization intent.
jsx
// ✅ Good: Component synchronizes once after mount
useEffect(() => {
document.title = 'Welcome to the app';
}, []); // Empty dependency array = run once after mount
// ✅ Good: Component re-synchronizes when count changes
useEffect(() => {
document.title = You clicked ${count} times;
}, [count]); // Dependency array with values to track
// ❌ Bad: Missing dependency array = run after every render
useEffect(() => {
document.title = You clicked ${count} times;
});
React's dependency array is not optional in terms of what you include. Include every value from the component scope that's used inside the effect.
jsx
// ✅ Good: All dependencies are properly declared
function SearchResults({ query, sortOrder }) {
const [results, setResults] = useState([]);
useEffect(() => {
async function fetchResults() {
const response = await fetch(/api/search?q=${query}&sort=${sortOrder});
const data = await response.json();
setResults(data);
}
fetchResults();
}, [query, sortOrder]); // Both query and sortOrder are dependencies
// ...
}
Many effects create resources that need to be disposed of when the component unmounts or before the effect runs again. Always return a cleanup function when appropriate.
jsx
// ✅ Good: Properly cleaning up subscriptions
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
connection.on('message', (message) => {
setMessages(prev => [...prev, message]);
});
// Return a cleanup function
return () => {
connection.disconnect();
};
}, [roomId]);
// ...
}
Separate concerns by using multiple useEffect hooks. This makes your code more maintainable and easier to understand.
jsx
// ✅ Good: Separating unrelated effects
function UserProfile({ userId }) {
// Effect 1: Fetch user data
useEffect(() => {
fetchUserData(userId).then(data => setUserData(data));
}, [userId]);
// Effect 2: Track page views
useEffect(() => {
logPageView('user-profile');
}, []);
// Effect 3: Set up keyboard shortcuts
useEffect(() => {
const handleKeyPress = (event) => {
if (event.key === 'Escape') closeProfile();
};
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, []);
// ...
}
When updating state based on previous state inside an effect, use the functional update pattern to avoid stale closures.
jsx
// ✅ Good: Using functional updates to avoid stale state
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCount(prevCount => prevCount + 1); // Using functional update
}, 1000);
return () => clearInterval(interval);
}, []); // Empty dependency array is fine with functional updates
// ...
}
The React team created the exhaustive-deps ESLint rule for a reason. Ignoring it with eslint-disable comments often leads to subtle bugs.
jsx
// ❌ Bad: Ignoring the eslint rule
useEffect(() => {
document.title = ${count} - ${user.name};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [count]); // Missing user dependency
// ✅ Good: Including all dependencies
useEffect(() => {
document.title = ${count} - ${user.name};
}, [count, user.name]);
If you're computing some data solely based on current props and state, you don't need an effect.
jsx
// ❌ Bad: Using effect for data transformation
function ProductList({ products }) {
const [filteredProducts, setFilteredProducts] = useState([]);
useEffect(() => {
setFilteredProducts(
products.filter(product => product.inStock)
);
}, [products]);
// ...
}
// ✅ Good: Computing values directly during render
function ProductList({ products }) {
const filteredProducts = useMemo(() => {
return products.filter(product => product.inStock);
}, [products]);
// or even simpler for this case:
// const filteredProducts = products.filter(product => product.inStock);
// ...
}
Async operations in effects can lead to race conditions if you're not careful.
jsx
// ❌ Bad: Potential race condition
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
async function fetchResults() {
const response = await fetch(/api/search?q=${query});
const data = await response.json();
setResults(data); // Might set results for an outdated query
}
fetchResults();
}, [query]);
// ...
}
// ✅ Good: Preventing race conditions
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
let isMounted = true;
async function fetchResults() {
const response = await fetch(/api/search?q=${query});
const data = await response.json();
if (isMounted) setResults(data);
}
fetchResults();
return () => {
isMounted = false;
};
}, [query]);
// ...
}
Constantly synchronizing with non-React systems on every render can cause performance issues and potential infinite loops.
jsx
// ❌ Bad: Over-synchronizing localStorage on every render
function SavedSettings() {
const [isDarkMode, setIsDarkMode] = useState(false);
// This will cause localStorage to be updated on every render
useEffect(() => {
localStorage.setItem('isDarkMode', JSON.stringify(isDarkMode));
});
// ...
}
// ✅ Good: Only synchronize when isDarkMode changes
function SavedSettings() {
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
localStorage.setItem('isDarkMode', JSON.stringify(isDarkMode));
}, [isDarkMode]);
// ...
}
Trying to mimic class component lifecycle methods with effects leads to convoluted code. Instead, think in terms of synchronization.
jsx
// ❌ Bad: Trying to mimic componentDidMount, componentDidUpdate, etc.
function ProfilePage({ userId }) {
const [isFirstRender, setIsFirstRender] = useState(true);
useEffect(() => {
if (isFirstRender) {
// "componentDidMount" logic
loadDefaultData();
setIsFirstRender(false);
} else {
// "componentDidUpdate" logic
loadUserSpecificData(userId);
}
}, [userId, isFirstRender]);
// ...
}
// ✅ Good: Thinking in terms of synchronization
function ProfilePage({ userId }) {
// Effect for one-time initialization
useEffect(() => {
loadDefaultData();
}, []);
// Separate effect that synchronizes with userId
useEffect(() => {
if (userId) {
loadUserSpecificData(userId);
}
}, [userId]);
// ...
}
Extract common effect patterns into custom hooks for reusability and abstraction.
jsx
// Custom hook for API fetching with loading/error states
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isMounted = true;
setLoading(true);
async function fetchData() {
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Network response was not ok');
const result = await response.json();
if (isMounted) {
setData(result);
setError(null);
}
} catch (err) {
if (isMounted) setError(err.message);
} finally {
if (isMounted) setLoading(false);
}
}
fetchData();
return () => {
isMounted = false;
};
}, [url]);
return { data, loading, error };
}
// Usage in component is now much cleaner
function UserProfile({ userId }) {
const { data: user, loading, error } = useFetch(/api/users/${userId});
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <div>{user.name}</div>;
}
For complex effects with many dependencies, you can decouple the effect from its dependencies using refs.
jsx
// Decoupling using refs for complex effects
function ChatRoom({ roomId, userId, theme }) {
// Store current values in refs to access in effect cleanup
const roomIdRef = useRef(roomId);
const userIdRef = useRef(userId);
// Update refs when props change
useEffect(() => {
roomIdRef.current = roomId;
userIdRef.current = userId;
});
// Main chat connection effect
useEffect(() => {
// Connect to chat service
const connection = createChatConnection(roomId, userId);
connection.connect();
return () => {
// Use current refs for cleanup to ensure latest values
connection.disconnect(roomIdRef.current, userIdRef.current);
};
}, [roomId, userId]); // Only recreate connection when these change
// Theme-related effect is separate and doesn't affect chat connection
useEffect(() => {
document.body.dataset.theme = theme;
}, [theme]);
// ...
}
For complex workflows that involve multiple sequential effects, you can orchestrate them with state.
jsx
// Effect orchestration for multi-step processes
function OrderCheckout() {
const [orderState, setOrderState] = useState('idle'); // idle, validating, processing, success, error
const [orderDetails, setOrderDetails] = useState(null);
const [error, setError] = useState(null);
// Step 1: Validate order when user submits
function handleSubmit(details) {
setOrderDetails(details);
setOrderState('validating');
}
// Effect for validation step
useEffect(() => {
if (orderState !== 'validating') return;
async function validateOrder() {
try {
const validationResult = await validateOrderAPI(orderDetails);
if (validationResult.valid) {
setOrderState('processing');
} else {
setError(validationResult.errors);
setOrderState('error');
}
} catch (err) {
setError('Validation failed');
setOrderState('error');
}
}
validateOrder();
}, [orderState, orderDetails]);
// Effect for processing step
useEffect(() => {
if (orderState !== 'processing') return;
async function processOrder() {
try {
await processOrderAPI(orderDetails);
setOrderState('success');
} catch (err) {
setError('Processing failed');
setOrderState('error');
}
}
processOrder();
}, [orderState, orderDetails]);
// Render different UI based on orderState
// ...
}
For effects with rapidly changing dependencies, throttle or debounce to limit the frequency of effect execution.
jsx
// Using debounce for search input
function SearchComponent() {
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState(query);
// Update debounced value after delay
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 500);
return () => clearTimeout(timer);
}, [query]);
// Only fetch results when debouncedQuery changes
useEffect(() => {
if (debouncedQuery) {
fetchSearchResults(debouncedQuery);
}
}, [debouncedQuery]);
return (
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
);
}
When your effect depends on complex objects or arrays, use useMemo to prevent unnecessary effect triggers.
jsx
// Using useMemo to stabilize complex dependencies
function ProductFilter({ products, filters }) {
// Memoize the filtered products to stabilize this dependency
const filteredProducts = useMemo(() => {
return products.filter(product => {
return Object.entries(filters).every(([key, value]) => {
return product[key] === value;
});
});
}, [products, filters]);
// This effect won't run unless filteredProducts actually changes
useEffect(() => {
logAnalytics('filtered-products-view', { count: filteredProducts.length });
}, [filteredProducts]);
// ...
}
For expensive initial setup, use lazy initialization to improve first render performance.
jsx
// Lazy initialization for localStorage
function SavedSettings() {
// Instead of reading from localStorage on every render
const [isDarkMode, setIsDarkMode] = useState(() => {
// This function runs only on first render
try {
const saved = localStorage.getItem('isDarkMode');
return saved ? JSON.parse(saved) : false;
} catch (e) {
return false;
}
});
// Save to localStorage when value changes
useEffect(() => {
localStorage.setItem('isDarkMode', JSON.stringify(isDarkMode));
}, [isDarkMode]);
// ...
}
jsx
function ProductPage({ productId }) {
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isMounted = true;
setLoading(true);
async function fetchProduct() {
try {
const response = await fetch(/api/products/${productId});
const data = await response.json();
if (isMounted) {
setProduct(data);
setError(null);
}
} catch (err) {
if (isMounted) setError('Failed to fetch product');
} finally {
if (isMounted) setLoading(false);
}
}
fetchProduct();
return () => {
isMounted = false;
};
}, [productId]);
// ...
}
jsx
function WindowSize() {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
function handleResize() {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
});
}
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []); // Empty array ensures the effect runs only once
// ...
}
jsx
function Map({ center, zoom }) {
const mapRef = useRef(null);
const [map, setMap] = useState(null);
// Initialize map
useEffect(() => {
if (mapRef.current && !map) {
const newMap = new GoogleMap(mapRef.current, {
center,
zoom
});
setMap(newMap);
}
}, [center, zoom, map]);
// Update map when props change
useEffect(() => {
if (map) {
map.setCenter(center);
map.setZoom(zoom);
}
}, [center, zoom, map]);
return <div ref={mapRef} style={{ height: '400px' }} />;
}
Despite its power, there are cases where useEffect isn't the right tool:
useMemo instead.Mastering useEffect is key to writing maintainable and efficient React applications. By understanding its true purpose—synchronizing your component with external systems—and following the DOs and DON'Ts outlined in this guide, you can avoid common pitfalls and unlock the full potential of React's hooks system.
Remember, the best effects are often the ones you don't need to write at all. Always consider if there's a more direct way to achieve your goal before reaching for useEffect.
About the Author: Ajithkumar R is a full-stack developer specializing in React and modern JavaScript frameworks. He has worked on multiple large-scale React applications and enjoys sharing his knowledge and experience with the community.
No comments yet.
Give a star rating and let me know your impressions.