Difficulty: Advanced
Every React component goes through three phases during its existence: mounting (being inserted into the DOM for the first time), updating (re-rendering due to state or prop changes), and unmounting (being removed from the DOM). Understanding these phases is crucial for managing side effects, subscriptions, and resource cleanup.
In class components, these phases map to explicit lifecycle methods: componentDidMount (after first render), componentDidUpdate (after re-renders), and componentWillUnmount (before removal). In function components, the useEffect hook consolidates all three. The mapping is: useEffect with [] runs on mount, useEffect with dependencies runs on mount and on updates when dependencies change, and the cleanup function returned from useEffect runs before the next effect and on unmount.
The dependency array is the most critical concept. An empty array [] means the effect runs only once on mount - equivalent to componentDidMount. Including specific values [count, userId] means the effect runs on mount and whenever those values change - similar to componentDidUpdate with a condition. Omitting the array entirely means the effect runs after every render, which is rarely what you want and is a common source of bugs.
The cleanup function handles resource deallocation. If you subscribe to an event listener, start a timer, or open a WebSocket connection in an effect, the cleanup function should unsubscribe, clear the timer, or close the connection. React calls the cleanup before running the next effect (to clean up the previous one) and before unmounting. This prevents memory leaks and zombie subscriptions.
A subtle point: effects run after the browser paints. React renders the component, the browser paints the new DOM, and then effects execute. This is different from useLayoutEffect, which runs synchronously after the DOM mutation but before the paint. Use useLayoutEffect only when you need to measure or modify the DOM before the user sees the update (e.g., measuring element dimensions for positioning).
function UserProfile({ userId }) {
const [user, setUser] = React.useState(null);
// Mount + Update: runs when userId changes
React.useEffect(() => {
console.log('Effect: Fetching user', userId);
fetchUser(userId).then(setUser);
// Cleanup: runs before next effect and on unmount
return () => {
console.log('Cleanup: Cancelling fetch for', userId);
};
}, [userId]);
// Mount only: runs once
React.useEffect(() => {
console.log('Mounted: UserProfile');
return () => console.log('Unmounted: UserProfile');
}, []);
return user ? <h1>{user.name}</h1> : <p>Loading...</p>;
}
// Timeline:
// 1. Component mounts -> both effects run
// 2. userId changes -> first effect cleanup runs, then first effect runs again
// 3. Component unmounts -> both cleanup functions run
The first effect syncs with userId - its cleanup cancels stale fetches. The second effect with [] handles one-time mount/unmount logic. React guarantees all cleanups run before unmounting.
// Class component lifecycle:
class MyComponent extends React.Component {
componentDidMount() {
// After first render
this.subscription = eventBus.subscribe(this.handleEvent);
}
componentDidUpdate(prevProps) {
// After re-renders
if (prevProps.id !== this.props.id) {
this.fetchData(this.props.id);
}
}
componentWillUnmount() {
// Before removal
this.subscription.unsubscribe();
}
}
// Equivalent hooks:
function MyComponent({ id }) {
// componentDidMount + componentWillUnmount
React.useEffect(() => {
const sub = eventBus.subscribe(handleEvent);
return () => sub.unsubscribe();
}, []);
// componentDidMount + componentDidUpdate (for id)
React.useEffect(() => {
fetchData(id);
}, [id]);
}
Hooks split lifecycle by concern (subscription vs data fetching) rather than by timing (mount vs update). This leads to better code organization and fewer bugs.
function TimerComponent() {
React.useEffect(() => {
// Setup: start interval
const id = setInterval(() => console.log('tick'), 1000);
// Cleanup: clear interval
return () => clearInterval(id);
}, []);
}
function ResizeWatcher() {
React.useEffect(() => {
// Setup: add listener
const handler = () => console.log(window.innerWidth);
window.addEventListener('resize', handler);
// Cleanup: remove listener
return () => window.removeEventListener('resize', handler);
}, []);
}
function WebSocketChat({ roomId }) {
React.useEffect(() => {
// Setup: connect
const ws = new WebSocket(`/ws/rooms/${roomId}`);
ws.onmessage = (e) => console.log('Message:', e.data);
// Cleanup: disconnect
return () => ws.close();
}, [roomId]); // Reconnect when room changes
}
Every setup should have a matching cleanup. Timers need clearInterval, listeners need removeEventListener, connections need close. React calls cleanup before the next effect execution and on unmount.
mounting, updating, unmounting, useEffect lifecycle, cleanup function, dependency array