Difficulty: Intermediate
The useEffect hook is React's primary mechanism for performing side effects in function components. Side effects are operations that reach outside the component's rendering logic - things like fetching data, subscribing to events, manually changing the DOM, or setting up timers. Before hooks, these operations were split across componentDidMount, componentDidUpdate, and componentWillUnmount in class components. useEffect unifies all three into a single API.
useEffect accepts two arguments: a setup function and an optional dependency array. The setup function runs after React commits changes to the DOM. If you return a function from the setup function, React treats it as a cleanup function and calls it before the effect runs again and when the component unmounts. This cleanup mechanism prevents memory leaks from subscriptions, timers, and other persistent resources.
The dependency array controls when the effect re-runs. If you pass an empty array [], the effect runs only once after the initial render - similar to componentDidMount. If you pass specific values [a, b], the effect re-runs only when those values change between renders. If you omit the array entirely, the effect runs after every render. Getting the dependency array right is one of the most important skills in React development.
React compares dependency values using Object.is (similar to ===). This means objects and arrays create new references on every render, which can cause effects to run more often than expected. Understanding reference equality is critical when working with useEffect dependencies.
In React 18's Strict Mode during development, effects are intentionally run twice (mount, unmount, mount) to help you find bugs related to missing cleanup functions. This does not happen in production builds.
function UserProfile({ userId }) {
const [user, setUser] = React.useState(null);
React.useEffect(() => {
console.log('Effect running for userId:', userId);
// Simulate fetching user data
const userData = { id: userId, name: 'User ' + userId };
setUser(userData);
// Cleanup function
return () => {
console.log('Cleanup for userId:', userId);
};
}, [userId]); // Only re-run when userId changes
console.log('Rendered user:', user ? user.name : 'loading');
return null;
}
The effect runs after the component renders. The cleanup function runs before the next effect execution or when the component unmounts. The dependency array [userId] ensures the effect only re-runs when userId changes.
function Counter() {
const [count, setCount] = React.useState(0);
// Runs only once after initial render
React.useEffect(() => {
console.log('Mount effect: runs once');
return () => console.log('Unmount cleanup');
}, []);
// Runs after every render
React.useEffect(() => {
console.log('Render effect: count is', count);
});
// Runs only when count changes
React.useEffect(() => {
console.log('Count effect: count changed to', count);
}, [count]);
return null;
}
Three different dependency patterns: empty array for mount-only, no array for every render, and specific dependencies for selective re-runs.
function Timer() {
const [seconds, setSeconds] = React.useState(0);
React.useEffect(() => {
console.log('Setting up interval');
const id = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
return () => {
console.log('Clearing interval');
clearInterval(id);
};
}, []); // Empty array: set up once, clean up on unmount
console.log('Current seconds:', seconds);
return null;
}
The cleanup function clears the interval when the component unmounts, preventing memory leaks. Using the functional updater form (s => s + 1) avoids needing seconds in the dependency array.
useEffect, side effects, cleanup functions, dependency array, component lifecycle