useEffect Hook Guide: Side Effects in React
The useEffect hook is React's primary mechanism for handling side effects—operations that interact with the world outside your component, such as fetching data, updating the document title, or setting up subscriptions. While React components should ideally be "pure functions" (the same props and state always return the same JSX), real applications need to interact with APIs, timers, and the DOM. useEffect provides a safe, predictable way to handle these operations without breaking React's rendering guarantees. This guide covers the syntax, practical examples, and the critical cleanup function pattern.
Understanding Side Effects in React
What is a side effect and why does React need a special hook for it?
A side effect is any operation that affects something outside the component's render scope. Pure React components should only receive props and state, then return JSX. But real applications need to:
- Fetch data from APIs
- Subscribe to WebSockets or event listeners
- Manipulate the DOM directly (update the document title, focus an input)
- Set up timers with
setTimeoutorsetInterval - Write to browser storage (localStorage, sessionStorage)
Running these operations directly in the component body causes problems: they'd execute during every render (even before the DOM updates), potentially running multiple times and causing memory leaks, inconsistent state, or performance degradation.
useEffect solves this by deferring side effects until after React has rendered and updated the DOM, ensuring they run safely, predictably, and only when necessary.
The useEffect Hook: Syntax and How It Works
Basic syntax: effect function, optional dependency array
import React, { useEffect, useState } from 'react';
function MyComponent() {
useEffect(() => {
// Your side effect code executes here
// Runs after every render (if no dependency array)
});
return <div>Component</div>;
}
The useEffect hook accepts a function as its first argument. React executes this function after rendering the component and updating the DOM. Without a second argument (dependency array), the effect runs after every render—useful for immediate sync operations but often inefficient.
When does useEffect run?
- After initial render — The component renders, React updates the DOM, then React calls your effect.
- After every re-render — When props or state change and the component re-renders, the effect runs again.
- Cleanup before next effect — If your effect returns a cleanup function, React calls it before running the effect again or when the component unmounts.
This timing is intentional: by running effects after rendering, React ensures the DOM is up-to-date and your effect has accurate state values.
Practical Example 1: Updating the Document Title
Synchronize component state with the browser's document title
A classic first useEffect example is updating the browser tab title to reflect component state:
import React, { useState, useEffect } from 'react';
function TitleUpdater() {
const [count, setCount] = useState(0);
useEffect(() => {
// Update document title after every render
document.title = `You clicked ${count} times`;
});
return (
<div>
<p>Watch the browser tab title update!</p>
<button onClick={() => setCount(count + 1)}>
Click me ({count})
</button>
</div>
);
}
export default TitleUpdater;
How this works:
- Component renders initially with
count = 0. React updates the DOM, then calls the effect. - The effect sets
document.title = "You clicked 0 times". - User clicks the button,
setCount(1)triggers a re-render. - Component renders with
count = 1. React updates the DOM, then runs the effect again. - The effect updates the title to "You clicked 1 times".
- This repeats every click.
The effect runs after every render because we didn't provide a dependency array. The document title stays in sync with the component's state automatically. This is why useEffect is essential: without it, you'd have to manually call a function to update the title after every state change, violating React's philosophy of declarative UI.
The Cleanup Function: Preventing Memory Leaks
Return a cleanup function from useEffect for side effects that create subscriptions or timers
Many side effects create ongoing listeners or timers that consume resources. If not cleaned up, they accumulate in memory even after the component unmounts. The cleanup function (also called the "return function") solves this:
import React, { useState, useEffect } from 'react';
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
console.log('Setting up the interval...');
// Create a timer that increments seconds every 1000ms
const intervalId = setInterval(() => {
setSeconds(s => s + 1); // Use functional update to access current state
}, 1000);
// Return a cleanup function
// React calls this before the component unmounts or before the effect runs again
return () => {
console.log('Cleaning up the interval!');
clearInterval(intervalId); // Stop the timer
};
}, []); // Empty dependency array: effect runs only once
return (
<div>
<h2>Timer: {seconds}s</h2>
<p>The timer will stop if you navigate away from this component.</p>
</div>
);
}
export default Timer;
How cleanup works:
- Component mounts. The effect runs once (due to the empty dependency array
[]). setIntervalcreates a timer that incrementssecondsevery second.- The effect returns a cleanup function that stores
clearInterval. - If the component unmounts (user navigates to a different page), React calls the cleanup function.
clearInterval(intervalId)stops the timer, freeing memory.
Without the cleanup function, the interval would keep running in the background forever, incrementing a state variable in a component that no longer exists—a classic memory leak. Always clean up subscriptions, timers, event listeners, and WebSocket connections.
Common Side Effects and Cleanup Patterns
Pattern: Fetching data from an API
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let isMounted = true; // Track if component is still mounted
const fetchUser = async () => {
setLoading(true);
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
// Only update state if component is still mounted
if (isMounted) {
setUser(data);
}
} catch (error) {
console.error('Failed to fetch user:', error);
} finally {
if (isMounted) {
setLoading(false);
}
}
};
fetchUser();
// Cleanup: mark component as unmounted
return () => {
isMounted = false;
};
}, [userId]); // Re-run effect if userId changes
if (loading) return <p>Loading...</p>;
return <div>{user.name}</div>;
}
This pattern prevents "state update on unmounted component" warnings. The isMounted flag ensures we only update state if the component is still visible.
Pattern: Adding and removing event listeners
function WindowResizer() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
// Attach listener after component renders
window.addEventListener('resize', handleResize);
// Cleanup: remove listener when component unmounts
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return <p>Window width: {width}px</p>;
}
Add listeners in the effect, remove them in the cleanup function. This prevents duplicate listeners from accumulating if the component re-mounts.
Pattern: Setting and clearing timers
function Debouncer({ onSearch }) {
const [query, setQuery] = useState('');
useEffect(() => {
// Set a timer that fires 500ms after user stops typing
const timeoutId = setTimeout(() => {
onSearch(query);
}, 500);
// Cleanup: cancel the timer if query changes before 500ms
return () => {
clearTimeout(timeoutId);
};
}, [query, onSearch]);
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
);
}
This implements debouncing: search only fires 500ms after the user stops typing. If they type again before 500ms, the old timer is cancelled and a new one starts.
Best Practices for useEffect
Write robust, maintainable side effects
- Return a cleanup function for stateful side effects — Timers, subscriptions, event listeners, and fetch requests all need cleanup.
- Don't mutate props or state directly — Use state setters and handlers; don't modify them directly.
- Avoid side effects in the component body — Always wrap them in
useEffect; running code directly in the component body causes it to execute during every render. - Use the
isMountedpattern for async operations — When fetching data, track whether the component is mounted to avoid state updates on unmounted components. - Be explicit with dependency arrays — We'll cover this in detail in the next article, but understand that the dependency array controls when effects run.
- Keep effects focused — One effect should do one thing (fetch data, set up a listener, update a title). If multiple unrelated side effects exist, use multiple
useEffectcalls.
Key Takeaways
- Side effects are operations that interact with the world outside your component (APIs, timers, DOM manipulation, subscriptions).
useEffectdefers side effects until after rendering, ensuring they run safely and don't break React's render cycle.- The cleanup function (returned from
useEffect) runs before the next effect or when the component unmounts, preventing memory leaks. - Always clean up timers, subscriptions, and event listeners — failure to do so causes memory leaks and bugs.
- Effects run after every render by default — a dependency array controls exactly when they run (covered in the next article).
- Multiple effects are fine — use separate
useEffectcalls for unrelated side effects rather than cramming everything into one.
Frequently Asked Questions
Why do side effects run after rendering instead of before?
React must update the DOM first so that the DOM accurately reflects the component's state. If effects ran before rendering, the DOM would be stale, and accessing document.title or measuring element sizes would give incorrect values. Running effects after ensures the DOM is current.
What happens if I forget to return a cleanup function for a timer?
The timer keeps running even after the component unmounts, continuing to increment state in a component that no longer exists. This causes a memory leak and triggers a React warning: "Can't perform a React state update on an unmounted component." Always return cleanup functions for subscriptions, timers, and listeners.
Can I use multiple useEffect hooks in one component?
Yes. In fact, using multiple effects is encouraged when they handle different concerns. One effect fetches data, another sets up a listener, another updates the title. This makes each effect's responsibility clear.
Does useEffect run on the server during SSR?
No. Effects only run in the browser. During server-side rendering, React renders components to static HTML but does not execute effects. This is why data fetching in effects happens only on the client. For SSR with data fetching, use frameworks like Next.js that provide specialized functions like getServerSideProps or getStaticProps.
Is useEffect only for data fetching?
No. Effects handle any side effect: DOM manipulation, timers, subscriptions, event listeners, browser storage, analytics tracking, etc. Essentially, anything that isn't pure rendering logic belongs in useEffect.