Understand side effects in React, master the useEffect hook with its dependency array, and learn common patterns for data fetching, timers, and cleanup.
React components are primarily responsible for computing and returning UI based on their props and state. This is the component's render logic — it should be pure, meaning it produces the same output for the same inputs without modifying anything outside itself.
However, real applications need to do more than just render UI. They need to:
These operations are called side effects because they affect things outside the component's render output. They interact with the "outside world" — the browser, the network, or other systems.
You should not perform side effects directly inside the render phase of a component. Doing so can cause bugs, infinite loops, and unpredictable behavior. Instead, React provides the useEffect hook to handle side effects safely.
// Wrong: side effect during render
function BadComponent() {
document.title = 'Hello'; // Runs every render — not ideal
return <p>Hello</p>;
}
// Correct: side effect in useEffect
function GoodComponent() {
React.useEffect(() => {
document.title = 'Hello';
}, []);
return <p>Hello</p>;
}The useEffect hook takes two arguments:
React.useEffect(() => {
// Side effect code here
}, [dependencies]);The dependency array determines when the effect re-runs:
1. Empty array [] — runs once on mount:
React.useEffect(() => {
console.log('Component mounted!');
// Runs once, after the first render
}, []);This is equivalent to componentDidMount in class components. Use this for one-time setup like fetching initial data or setting up a subscription.
2. Array with dependencies [a, b] — runs when dependencies change:
React.useEffect(() => {
console.log('Count changed to:', count);
document.title = `Count: ${count}`;
}, [count]);The effect runs after the first render and every time count changes. React compares the current value of each dependency with its previous value using Object.is(). If any dependency has changed, the effect runs again.
3. No dependency array — runs after every render:
React.useEffect(() => {
console.log('Component rendered');
// Runs after EVERY render — use sparingly!
});This is rarely what you want. Without a dependency array, the effect runs after every single render, which can cause performance issues or infinite loops if the effect updates state.
Rules for dependencies:
count, include count in the array.react-hooks/exhaustive-deps) can help catch missing dependencies.Some side effects need to be cleaned up when the component unmounts or before the effect re-runs. For example, if you set up a timer, you need to clear it. If you subscribe to an event, you need to unsubscribe.
The setup function can return a cleanup function. React will call this cleanup function:
React.useEffect(() => {
// Setup: start a timer
const timer = setInterval(() => {
console.log('Tick');
}, 1000);
// Cleanup: stop the timer
return () => {
clearInterval(timer);
};
}, []); // Empty deps: set up once, clean up on unmountEvent listeners:
React.useEffect(() => {
function handleResize() {
console.log('Window size:', window.innerWidth);
}
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);Why cleanup matters: Without cleanup, you get memory leaks. If a component mounts and unmounts many times (e.g., navigating between pages), each mount would create a new timer or listener without removing the old ones. Over time, this degrades performance and causes bugs.
Cleanup with dependencies:
When an effect has dependencies and re-runs, React calls the cleanup from the previous effect before running the new one:
React.useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => {
connection.disconnect(); // Disconnect from old room
};
}, [roomId]); // Re-runs when roomId changesWhen roomId changes: disconnect from the old room (cleanup), then connect to the new room (setup).
Here are the most common patterns for useEffect:
1. Updating the document title:
function PageTitle({ title }) {
React.useEffect(() => {
document.title = title;
}, [title]);
return <h1>{title}</h1>;
}2. Fetching data on mount:
function UserProfile({ userId }) {
const [user, setUser] = React.useState(null);
React.useEffect(() => {
let cancelled = false;
async function fetchUser() {
const res = await fetch(`/api/users/${userId}`);
const data = await res.json();
if (!cancelled) {
setUser(data);
}
}
fetchUser();
return () => {
cancelled = true; // Prevent state update if component unmounted
};
}, [userId]);
if (!user) return <p>Loading...</p>;
return <p>{user.name}</p>;
}The cancelled flag prevents setting state after the component has unmounted, which would cause a React warning.
3. Setting up a timer:
function Clock() {
const [time, setTime] = React.useState(new Date());
React.useEffect(() => {
const timer = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(timer);
}, []);
return <p>{time.toLocaleTimeString()}</p>;
}4. Listening to browser events:
function ScrollTracker() {
const [scrollY, setScrollY] = React.useState(0);
React.useEffect(() => {
const handleScroll = () => setScrollY(window.scrollY);
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return <p>Scrolled: {scrollY}px</p>;
}5. Syncing with localStorage:
function usePersistentState(key, initial) {
const [value, setValue] = React.useState(() => {
const saved = localStorage.getItem(key);
return saved !== null ? JSON.parse(saved) : initial;
});
React.useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}<div id="root"></div>
<script src="https://unpkg.com/react@19/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@19/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
const { useState, useEffect } = React;
function App() {
const [count, setCount] = useState(0);
const [mounted, setMounted] = useState(false);
// Effect that runs once on mount
useEffect(() => {
setMounted(true);
document.title = 'useEffect Demo';
}, []);
// Effect that runs when count changes
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
return (
<div>
<h2>useEffect Demo</h2>
<p>Component mounted: {mounted ? 'Yes' : 'No'}</p>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<p><em>Check the browser tab title — it updates with the count!</em></p>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>What does an empty dependency array [] in useEffect mean?