React Lifecycle Methods — Why Your setInterval Leaks Memory
Dashboard tab consumes 200MB immediately, climbs to 1.5GB+ in 30 minutes from missing clearInterval in componentWillUnmount.
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- React components have three predictable phases: mounting, updating, and unmounting
- componentDidMount runs once after DOM insertion — your safe zone for data fetching
- componentDidUpdate fires after every re-render — always add a comparison guard
- componentWillUnmount cleans up timers, subscriptions, and listeners — skip it and you leak memory
- useEffect collapses all three phases into one API with dependency array control
- Missing cleanup is the #1 cause of "Can't perform a React state update on an unmounted component" warnings
React lifecycle methods are the hooks React gives you to run code at specific points during a component's existence — mounting, updating, and unmounting. They exist because React components don't just render once; they live, change, and die. Without them, you'd have no reliable way to fetch data when a component appears, react to prop changes, or clean up subscriptions like setInterval before the component disappears.
That last one is why your intervals leak memory: if you start an interval in componentDidMount but never clear it in componentWillUnmount, the callback keeps running against a dead DOM node, holding references that the garbage collector can't touch.
These methods form a contract: React guarantees the order and timing of calls, but you're responsible for honoring the cleanup side. The three phases are mount (constructor -> render -> componentDidMount), update (new props/state -> render -> componentDidUpdate), and unmount (componentWillUnmount).
In class components, componentDidMount is where you kick off AJAX calls, WebSocket connections, or timers. componentDidUpdate lets you compare previous and current props to avoid infinite loops. componentWillUnmount is your only chance to tear down what you started — clearInterval, removeEventListener, abort fetch requests.
In modern React with hooks, useEffect replaces all three: a single effect can run on mount, on updates (via dependency array), and on unmount (via the cleanup function). But the underlying lifecycle contract hasn't changed — it's just expressed differently.
If you're still using class components or migrating to hooks, understanding the original lifecycle is essential: it's the mental model that explains why useEffect's dependency array exists and why forgetting the cleanup function causes the same memory leaks as skipping componentWillUnmount. Tools like React DevTools profiler and Chrome's Performance tab can confirm these leaks by showing detached DOM nodes and growing heap snapshots.
Think of a React component like a houseplant. When you first bring it home and pot it, that's mounting — it's coming to life. Every time you water it or it grows a new leaf, that's updating — it's reacting to change. When it finally dies and you throw it out, that's unmounting — cleanup time. Lifecycle methods are just the specific moments React taps you on the shoulder and says 'hey, something just happened to your component — do you want to do anything about it?'
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
Every React app you've ever used — a Twitter feed refreshing in real time, a form that validates as you type, a dashboard that polls an API every 30 seconds — depends on lifecycle methods working correctly behind the scenes. They're not a niche feature; they're the engine that drives dynamic behavior. If you've ever seen a component that shows stale data, triggers a memory leak, or fires an API call 400 times, a misunderstood lifecycle method was almost certainly the culprit.
The problem lifecycle methods solve is timing. JavaScript is asynchronous and the DOM is constantly changing, so you need a reliable way to say 'run this code ONLY after the component appears on screen' or 'clean this up BEFORE the component disappears.' Without lifecycle hooks, you'd be firing code at the wrong moment — fetching data before the component exists, or leaving open WebSocket connections after the user has navigated away.
After reading this article you'll know exactly which lifecycle method to reach for in three critical scenarios: fetching data on load, responding to prop or state changes, and cleaning up subscriptions or timers. You'll also understand the modern hooks equivalents so you can work confidently in both class-based and functional codebases.
Why React Component Lifecycle Is a Contract, Not a Timeline
React lifecycle methods are hooks into the component's existence — mount, update, unmount — that let you synchronize side effects with the render cycle. The core mechanic: React calls these methods in a deterministic order, and you use them to allocate or release resources (timers, subscriptions, network requests) that must align with the component's presence in the DOM.
In practice, the critical sequence is componentDidMount → componentDidUpdate → componentWillUnmount. componentDidMount runs once after the first render; componentWillUnmount runs once before removal. Between updates, componentDidUpdate fires after every re-render, giving you access to previous props/state for diffing. The key property: React guarantees unmount will always fire if mount fired — but only if you don't throw during render.
Use lifecycle methods when you need imperative control over resources that React's declarative model can't manage: timers, WebSocket connections, third-party DOM libraries, or analytics pings. The real-world impact: forgetting to clear a setInterval in componentWillUnmount means the callback continues executing on a dead component, leaking memory and potentially mutating unmounted state — a classic source of 'setState on unmounted component' warnings and silent data corruption.
The Three Phases Every React Component Goes Through
React components have a predictable life. They're born (mounting), they change (updating), and they die (unmounting). Each phase gives you specific hooks to run your own code at exactly the right moment.
Mounting happens once — when the component first appears in the DOM. This is where you kick off initial data fetching, set up subscriptions, or read from localStorage. It only runs once per component instance, which is why so many developers reach for it first.
Updating happens every time props or state change. React re-renders the component and gives you lifecycle hooks both before and after that re-render. This is where you respond to changes — maybe a user selected a different user ID from a dropdown and you need to fetch that user's data.
Unmounting happens when the component is removed from the DOM — the user navigates away, a conditional renders the component out, or the parent unmounts. This is your cleanup phase. Anything you set up in mounting that isn't React-managed (timers, event listeners, WebSocket connections) must be torn down here, or you'll leak memory.
Understanding that these three phases exist — and are sequential — is the foundation everything else builds on.
import React, { Component } from 'react'; // A simple component that logs each lifecycle phase clearly // so you can see the ORDER in which they fire in the console class UserProfileCard extends Component { constructor(props) { super(props); // constructor runs first — before anything is painted to the DOM this.state = { profileData: null, isLoading: true, }; console.log('[1] constructor — component instance created'); } // Fires ONCE after the component appears in the DOM componentDidMount() { console.log('[3] componentDidMount — component is now visible in the DOM'); // Safe to fetch data here because the DOM node exists this.fetchUserProfile(this.props.userId); } // Fires after EVERY re-render caused by props or state change componentDidUpdate(previousProps, previousState) { console.log('[5] componentDidUpdate — something changed and React re-rendered'); // IMPORTANT: always compare previous vs current before acting // Without this check you get an infinite loop (fetch → setState → re-render → fetch...) if (previousProps.userId !== this.props.userId) { console.log('userId prop changed — fetching new profile'); this.fetchUserProfile(this.props.userId); } } // Fires just before the component is removed from the DOM componentWillUnmount() { console.log('[6] componentWillUnmount — time to clean up'); // If we had a timer or subscription set up in componentDidMount, // we'd cancel it here to avoid memory leaks clearTimeout(this.refreshTimer); } fetchUserProfile(userId) { this.setState({ isLoading: true }); // Simulating an API call with setTimeout this.refreshTimer = setTimeout(() => { this.setState({ profileData: { id: userId, name: 'Ada Lovelace', role: 'Engineer' }, isLoading: false, }); console.log('[4] setState called — triggers re-render and componentDidUpdate'); }, 500); } render() { // render() fires BEFORE componentDidMount on first load // and BEFORE componentDidUpdate on subsequent renders console.log('[2/re-render] render — React is building the virtual DOM'); const { isLoading, profileData } = this.state; if (isLoading) return <p>Loading profile...</p>; return ( <div className="profile-card"> <h2>{profileData.name}</h2> <p>Role: {profileData.role}</p> </div> ); } } export default UserProfileCard;
componentDidMount — Your Go-To for Data Fetching and Subscriptions
componentDidMount is the most commonly used lifecycle method, and for good reason — it fires exactly once, right after the component is painted to the screen. The DOM node exists, refs are populated, and you're guaranteed a real browser environment. That makes it the perfect place to kick off async operations.
The key insight most tutorials miss: you're not just 'fetching data here because that's the pattern.' You're fetching here because React guarantees the component is mounted before this runs, so any setState calls you make in response to the fetch will trigger a re-render correctly. If you tried to call setState before mounting, React would either throw or silently discard your update.
Beyond data fetching, componentDidMount is where you set up anything that needs a real DOM node — third-party charting libraries, manual event listeners on window or document, or WebSocket connections. The golden rule: if you set it up here, you must tear it down in componentWillUnmount.
With hooks, componentDidMount maps to useEffect(() => { ... }, []) — the empty dependency array is what makes it run once.
import React, { Component } from 'react'; // Real-world pattern: a dashboard that fetches data on mount // AND sets up a polling interval to refresh every 60 seconds class WeatherDashboard extends Component { constructor(props) { super(props); this.state = { weatherData: null, errorMessage: null, lastUpdated: null, }; // We'll store the interval ID so we can clear it on unmount this.pollingInterval = null; } async componentDidMount() { // First fetch happens immediately when the component mounts await this.loadWeatherData(); // Then we set up polling — fetch fresh data every 60 seconds // Store the interval ID so componentWillUnmount can clean it up this.pollingInterval = setInterval(async () => { console.log('Polling: refreshing weather data...'); await this.loadWeatherData(); }, 60000); // 60,000ms = 60 seconds } async loadWeatherData() { const { cityName } = this.props; try { // In a real app this would be: fetch(`/api/weather?city=${cityName}`) const response = await fetch( `https://api.open-meteo.com/v1/forecast?latitude=51.5&longitude=-0.1¤t_weather=true` ); if (!response.ok) { throw new Error(`API returned status ${response.status}`); } const data = await response.json(); // setState here is safe — component is definitely mounted this.setState({ weatherData: data.current_weather, errorMessage: null, lastUpdated: new Date().toLocaleTimeString(), }); } catch (fetchError) { // Always handle errors — never let a rejected promise go silent this.setState({ errorMessage: `Could not load weather: ${fetchError.message}`, }); } } componentWillUnmount() { // CRITICAL: clear the interval when the component unmounts // Without this, the interval keeps firing even after the component // is gone, causing setState calls on an unmounted component if (this.pollingInterval) { clearInterval(this.pollingInterval); console.log('WeatherDashboard unmounted — polling interval cleared'); } } render() { const { weatherData, errorMessage, lastUpdated } = this.state; if (errorMessage) { return <div className="error-banner">{errorMessage}</div>; } if (!weatherData) { return <div className="skeleton-loader">Loading weather...</div>; } return ( <div className="weather-card"> <h3>London Weather</h3> <p>Temperature: {weatherData.temperature}°C</p> <p>Wind Speed: {weatherData.windspeed} km/h</p> <small>Last updated: {lastUpdated}</small> </div> ); } } export default WeatherDashboard;
componentDidUpdate — Responding Intelligently to Change
If componentDidMount is 'do this once on load,' then componentDidUpdate is 'do this whenever something specific changes.' It receives the previous props and previous state as arguments, which is what makes it powerful — you can compare old vs new and react only when it matters.
The single most important rule: always wrap your logic in a conditional comparison. componentDidUpdate fires after every re-render, including renders caused by the setState call inside it. Without a comparison guard, you'll create an infinite loop: state changes → componentDidUpdate fires → setState called → state changes → componentDidUpdate fires...
The comparison pattern (previousProps.someValue !== this.props.someValue) is essentially saying 'only do the expensive work if the relevant input actually changed.' This is the same thinking behind React.memo and useMemo — skip work that doesn't need to happen.
A real-world use case: imagine a data table where the user can select a date range. Every time the date range changes, you need to re-fetch the report. componentDidUpdate lets you watch for exactly that change and respond to it, without rebuilding the entire component.
In hooks, this maps to useEffect with specific dependencies: useEffect(() => { ... }, [dateRange]).
import React, { Component } from 'react'; // Real-world pattern: a report that re-fetches when filters change // This is the most common use case for componentDidUpdate class SalesReportTable extends Component { constructor(props) { super(props); this.state = { reportRows: [], isFetching: false, totalRevenue: 0, }; } componentDidMount() { // Fetch the initial report using the starting prop values this.fetchSalesReport(this.props.startDate, this.props.endDate, this.props.regionFilter); } componentDidUpdate(previousProps) { // Check if ANY of the filter props changed // We check ALL relevant props in one condition to avoid multiple fetches const dateRangeChanged = previousProps.startDate !== this.props.startDate || previousProps.endDate !== this.props.endDate; const regionChanged = previousProps.regionFilter !== this.props.regionFilter; // Only re-fetch if something that affects the report actually changed // This guard is what prevents the infinite loop if (dateRangeChanged || regionChanged) { console.log('Filters changed — fetching updated sales report'); this.fetchSalesReport( this.props.startDate, this.props.endDate, this.props.regionFilter ); } // You can also react to STATE changes, not just prop changes // For example: scroll to bottom when new rows are added if (previousProps.reportRows !== this.state.reportRows && this.tableRef) { this.tableRef.scrollTop = this.tableRef.scrollHeight; } } async fetchSalesReport(startDate, endDate, region) { // Set loading state — triggers a re-render but componentDidUpdate // won't re-fetch because the props haven't changed this.setState({ isFetching: true }); try { // Simulated API response const mockRows = [ { id: 1, product: 'Enterprise License', revenue: 12000, region }, { id: 2, product: 'Pro Subscription', revenue: 3400, region }, { id: 3, product: 'Consulting Hours', revenue: 5600, region }, ]; const totalRevenue = mockRows.reduce((sum, row) => sum + row.revenue, 0); this.setState({ reportRows: mockRows, totalRevenue, isFetching: false, }); } catch (error) { this.setState({ isFetching: false }); console.error('Failed to fetch report:', error); } } render() { const { reportRows, isFetching, totalRevenue } = this.state; const { startDate, endDate, regionFilter } = this.props; return ( <div className="report-container"> <h3> Sales Report: {startDate} to {endDate} ({regionFilter}) </h3> {isFetching ? ( <p>Refreshing data...</p> ) : ( <table> <thead> <tr><th>Product</th><th>Revenue</th><th>Region</th></tr> </thead> <tbody> {reportRows.map((row) => ( <tr key={row.id}> <td>{row.product}</td> <td>${row.revenue.toLocaleString()}</td> <td>{row.region}</td> </tr> ))} </tbody> <tfoot> <tr><td colSpan="2"><strong>Total: ${totalRevenue.toLocaleString()}</strong></td></tr> </tfoot> </table> )} </div> ); } } export default SalesReportTable;
componentWillUnmount — The Cleanup Phase You Can't Afford to Skip
componentWillUnmount is the most overlooked lifecycle method, and it's the one that causes the most insidious production bugs. It fires right before the component is removed from the DOM — your last chance to clean up anything that isn't managed by React's reconciliation engine.
The stuff you must clean up: timers (setInterval, setTimeout), event listeners on window or document, WebSocket or SignalR connections, third-party library instances, observer subscriptions (ResizeObserver, IntersectionObserver), and in-flight fetch requests. If you created it in componentDidMount and it's not a React-managed DOM node, it belongs in componentWillUnmount.
Here's what happens when you don't clean up: the timer or subscription keeps running, but the component it references is gone. If that callback calls setState, you get the infamous 'Can't perform a React state update on an unmounted component' warning. More importantly, you're leaking memory — the old component tree can't be garbage collected because the timer closure holds a reference to it. In a long-running SPA, components accumulate, memory grows, and eventually the browser tab crashes.
With hooks, the cleanup function returned from useEffect handles this. Every time the effect re-runs, the previous cleanup runs first. When the component unmounts, the final cleanup runs. This co-location of setup and teardown is one of the strongest arguments for functional components.
import React, { Component, createRef } from 'react'; // Real-world scenario: a chat panel that connects to a WebSocket // on mount and MUST disconnect on unmount to prevent resource leaks class ChatPanel extends Component { constructor(props) { super(props); this.state = { messages: [], onlineUsers: 0, connectionStatus: 'disconnected', }; this.chatSocket = null; this.reconnectTimer = null; this.scrollRef = createRef(); } componentDidMount() { // Connect to the chat WebSocket this.connectToChat(this.props.channelId); // Add a resize listener to adjust chat dimensions window.addEventListener('resize', this.handleWindowResize); } componentDidUpdate(prevProps) { // If the user switches to a different chat channel, // disconnect from old channel and connect to new one if (prevProps.channelId !== this.props.channelId) { console.log('Channel changed — reconnecting...'); this.disconnectFromChat(); this.connectToChat(this.props.channelId); } } componentWillUnmount() { // THREE things must be cleaned up: // 1. WebSocket connection // 2. Reconnect timer // 3. Window resize listener this.disconnectFromChat(); // Clear any scheduled reconnect if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; console.log('Reconnect timer cleared'); } // Remove event listener — using the EXACT same reference as addEventListener window.removeEventListener('resize', this.handleWindowResize); console.log('ChatPanel unmounted — all resources released'); } // Stored as a class property so we can reference it in both addEventListener // and removeEventListener with the exact same function reference handleWindowResize = () => { console.log('Window resized — adjusting chat dimensions'); // Adjust chat UI dimensions }; connectToChat(channelId) { console.log(`Connecting to chat channel: ${channelId}`); this.setState({ connectionStatus: 'connecting' }); // Simulate a WebSocket connection using setInterval // A real implementation would use: new WebSocket(`wss://chat.example.com/${channelId}`) this.chatSocket = setInterval(() => { const mockMessages = [ { id: Date.now(), user: 'Alice', text: 'Hey everyone!' }, { id: Date.now() + 1, user: 'Bob', text: 'Morning!' }, ]; // Simulate receiving a new message const randomMessage = mockMessages[Math.floor(Math.random() * mockMessages.length)]; this.setState((prevState) => ({ messages: [...prevState.messages, { ...randomMessage, timestamp: Date.now() }], onlineUsers: Math.floor(Math.random() * 50) + 10, connectionStatus: 'connected', })); }, 4000); // Simulate initial connection delay setTimeout(() => { this.setState({ connectionStatus: 'connected' }); }, 500); } disconnectFromChat() { if (this.chatSocket) { clearInterval(this.chatSocket); this.chatSocket = null; this.setState({ connectionStatus: 'disconnected' }); console.log('Disconnected from chat'); } } render() { const { messages, onlineUsers, connectionStatus } = this.state; return ( <div className="chat-panel" ref={this.scrollRef}> <div className="chat-header"> <span>Online users: {onlineUsers}</span> <span className={`connection-badge ${connectionStatus}`}> {connectionStatus} </span> </div> <div className="chat-messages"> {messages.map((msg) => ( <div key={msg.id} className="message-row"> <strong>{msg.user}:</strong> {msg.text} </div> ))} </div> </div> ); } } export default ChatPanel;
Hooks vs Class Lifecycle Methods — The Modern Equivalent
Class lifecycle methods aren't going anywhere — you'll encounter them in every legacy codebase. But since React 16.8, hooks have become the modern way to handle the same concerns in functional components. Knowing both, and how they map to each other, is what separates a solid React developer from one who only knows the happy path.
The key mental shift: useEffect doesn't map one-to-one to a single lifecycle method. It can replicate all three — mounting, updating, and unmounting — depending on how you configure it. The dependency array is the control mechanism.
An empty array [] means 'run once after mount' — that's componentDidMount. A populated array [userId] means 'run after mount AND after any render where userId changed' — that's componentDidMount plus componentDidUpdate with a comparison guard built in. No array at all means 'run after every render' — dangerous, but it exists. And the return value of useEffect is the cleanup function — that's componentWillUnmount.
The functional approach collapses three methods into one concept, which is more composable. But the class approach makes the phases more explicit, which can actually be easier to reason about when learning.
import React, { Component, useState, useEffect } from 'react'; // ───────────────────────────────────────────────────────── // CLASS COMPONENT VERSION — explicit lifecycle methods // ───────────────────────────────────────────────────────── class NotificationPanelClass extends Component { constructor(props) { super(props); this.state = { notifications: [], connectionStatus: 'disconnected' }; this.socketConnection = null; } componentDidMount() { // Set up a WebSocket-like subscription when the component mounts this.connectToNotificationStream(this.props.userId); } componentDidUpdate(previousProps) { // If the user changes (e.g. admin views another user's notifications), // disconnect the old stream and connect to the new one if (previousProps.userId !== this.props.userId) { this.disconnectFromStream(); this.connectToNotificationStream(this.props.userId); } } componentWillUnmount() { // Always close the connection when the panel is hidden/removed this.disconnectFromStream(); } connectToNotificationStream(userId) { console.log(`Connecting to stream for user: ${userId}`); this.setState({ connectionStatus: 'connected' }); // Simulating a real-time notification stream this.socketConnection = setInterval(() => { this.setState((previousState) => ({ notifications: [ ...previousState.notifications, { id: Date.now(), message: `New alert for user ${userId}` }, ], })); }, 3000); } disconnectFromStream() { if (this.socketConnection) { clearInterval(this.socketConnection); this.socketConnection = null; this.setState({ connectionStatus: 'disconnected' }); console.log('Disconnected from notification stream'); } } render() { const { notifications, connectionStatus } = this.state; return ( <div> <span>Status: {connectionStatus}</span> <ul>{notifications.map((n) => <li key={n.id}>{n.message}</li>)}</ul> </div> ); } } // ───────────────────────────────────────────────────────── // FUNCTIONAL COMPONENT VERSION — same logic, hooks style // Notice how useEffect with a cleanup return collapses // componentDidMount + componentDidUpdate + componentWillUnmount // ───────────────────────────────────────────────────────── function NotificationPanelHooks({ userId }) { const [notifications, setNotifications] = useState([]); const [connectionStatus, setConnectionStatus] = useState('disconnected'); useEffect(() => { // This block = componentDidMount + componentDidUpdate (when userId changes) console.log(`Connecting to stream for user: ${userId}`); setConnectionStatus('connected'); const streamInterval = setInterval(() => { setNotifications((previousNotifications) => [ ...previousNotifications, { id: Date.now(), message: `New alert for user ${userId}` }, ]); }, 3000); // The return function = componentWillUnmount // React also calls this cleanup BEFORE re-running the effect // when userId changes — so we get automatic reconnection logic return () => { clearInterval(streamInterval); setConnectionStatus('disconnected'); console.log('Disconnected from notification stream'); }; }, [userId]); // Only re-run this effect when userId changes return ( <div> <span>Status: {connectionStatus}</span> <ul>{notifications.map((n) => <li key={n.id}>{n.message}</li>)}</ul> </div> ); } export { NotificationPanelClass, NotificationPanelHooks };
Class Lifecycle Methods vs Hooks — Quick Reference Table
Use this table to quickly map class lifecycle methods to their hooks equivalent and understand when each is used.
| Class Lifecycle Method | Phase | Hooks Equivalent | When to Use |
|---|---|---|---|
| constructor | Mounting | useState initialiser | Set initial state and bind methods. Avoid side effects. |
| static getDerivedStateFromProps | Mount/Update | useState + useEffect with dep, or key prop | Rarely needed. Used when state must be synced with props before render (e.g., form reset on user change). |
| shouldComponentUpdate | Update | React.memo (for props), useMemo, useCallback | Performance optimisation. Prevent re-render when props/state haven't changed. |
| render | All | Function body | Pure function that returns JSX. No side effects. |
| getSnapshotBeforeUpdate | Update | useLayoutEffect | Capture DOM values (scroll position, cursor) before update. Used with componentDidUpdate. |
| componentDidMount | Mounting | useEffect(() => {}, []) | Data fetching, subscriptions, DOM manipulation after component is visible. |
| componentDidUpdate | Update | useEffect(() => {}, [dep]) | React to prop/state changes with side effects. Always compare prev vs current. |
| componentWillUnmount | Unmount | return () => { } in useEffect | Cleanup timers, subscriptions, event listeners, cancel async requests. |
| componentDidCatch | Error Handling | (No hook equivalent) | Log errors in error boundary component. |
| static getDerivedStateFromError | Error Handling | (No hook equivalent) | Set hasError state to show fallback UI in error boundary. |
Key takeaway: Hooks consolidate lifecycle logic into one API with dependency arrays, but error handling still requires class components. Use this table as a cheat sheet for migrating class components to hooks or for interview preparation.
Rarely Used Lifecycle Methods — getDerivedStateFromProps, shouldComponentUpdate, and getSnapshotBeforeUpdate
Most React applications never need these lifecycle methods. They exist for edge cases where the standard state-and-props flow isn't sufficient. But when you need them — form validation against props, performance optimisations, or reading DOM values right before a change — knowing they exist will save hours of workarounds.
getDerivedStateFromProps is a static method that runs before every render, both on mount and on update. It receives nextProps and prevState, and returns an object to update state, or null for no update. The classic use case: a form that resets when a user ID prop changes — the new user should see blank fields, not the previous user's data. Before React 16.3, you'd have used componentWillReceiveProps (now deprecated). This method is the replacement. It's static, so you cannot access 'this' or call other instance methods — it's purely for state derivation.
shouldComponentUpdate is the performance escape hatch. It receives nextProps and nextState, and returns true (default) to allow a re-render, or false to skip it. Use this when a component re-renders too often on props or state changes that don't affect its output. A pure functional component with React.memo gives you this optimization automatically; for class components, you implement shouldComponentUpdate manually, or extend PureComponent which does a shallow prop and state comparison for you.
getSnapshotBeforeUpdate runs right before React applies the DOM changes from a render. It receives prevProps and prevState, and returns a value that gets passed as the third argument to componentDidUpdate. The classic use case: preserving scroll position when adding new messages to a chat — you capture the scroll height before the DOM updates, then restore it after the update completes. Without this method, the scroll position jumps to the bottom when new messages arrive, breaking the user's reading position.
These methods are rarely used by design. If you find yourself reaching for them frequently, reconsider whether your component structure is correct. They are tools of last resort, not everyday patterns.
import React, { Component } from 'react'; // ============================================================ // EXAMPLE 1: getDerivedStateFromProps — Reset form when userId changes // ============================================================ class UserProfileForm extends Component { constructor(props) { super(props); this.state = { name: '', email: '', prevUserId: props.userId, // Track the last userId that caused a reset }; } static getDerivedStateFromProps(nextProps, prevState) { // If the userId prop changed, reset the form fields if (nextProps.userId !== prevState.prevUserId) { return { name: '', email: '', prevUserId: nextProps.userId, }; } // No state update needed return null; } handleChange = (field, value) => { this.setState({ [field]: value }); }; render() { const { name, email } = this.state; const { userId } = this.props; return ( <div> <h3>Editing User: {userId}</h3> <input placeholder="Name" value={name} onChange={(e) => this.handleChange('name', e.target.value)} /> <input placeholder="Email" value={email} onChange={(e) => this.handleChange('email', e.target.value)} /> </div> ); } } // ============================================================ // EXAMPLE 2: shouldComponentUpdate — Performance optimization // ============================================================ class ExpensiveChart extends Component { // Only re-render if the data array reference or color actually changed // Without this, every parent re-render would trigger a full chart re-draw shouldComponentUpdate(nextProps, nextState) { // Compare data references (assume immutable data) if (this.props.data !== nextProps.data) return true; // Compare color string if (this.props.color !== nextProps.color) return true; // No changes that affect the chart return false; } render() { const { data, color } = this.props; console.log('ExpensiveChart re-rendered'); return <div style={{ color }}>Rendering chart with {data.length} points</div>; } } // ============================================================ // EXAMPLE 3: getSnapshotBeforeUpdate — Preserve scroll position // ============================================================ class ChatMessageList extends Component { constructor(props) { super(props); this.messageContainerRef = React.createRef(); this.state = { messages: [] }; } getSnapshotBeforeUpdate(prevProps, prevState) { // Capture the scroll height before the DOM updates const container = this.messageContainerRef.current; if (container) { return { scrollHeightBefore: container.scrollHeight, scrollTopBefore: container.scrollTop, }; } return null; } componentDidUpdate(prevProps, prevState, snapshot) { if (snapshot && this.messageContainerRef.current) { const container = this.messageContainerRef.current; const newScrollHeight = container.scrollHeight; const heightIncrease = newScrollHeight - snapshot.scrollHeightBefore; // If the user was scrolled near the bottom, keep them at the bottom const wasNearBottom = snapshot.scrollTopBefore + container.clientHeight >= snapshot.scrollHeightBefore - 20; if (wasNearBottom) { container.scrollTop = newScrollHeight; } } } addMessage = () => { this.setState((prev) => ({ messages: [ ...prev.messages, `Message at ${new Date().toLocaleTimeString()}`, ], })); }; render() { return ( <div> <div ref={this.messageContainerRef} style={{ height: '200px', overflowY: 'auto', border: '1px solid #ccc' }} > {this.state.messages.map((msg, i) => ( <div key={i}>{msg}</div> ))} </div> <button onClick={this.addMessage}>Add Message</button> </div> ); } } export { UserProfileForm, ExpensiveChart, ChatMessageList };
Visual Lifecycle Diagram — Mounting, Updating, and Unmounting Flow
Understanding the lifecycle order visually helps you predict when your code will run. The diagram below shows the complete sequence from component creation to destruction, including the rarely used methods.
Mounting Phase (component birth): constructor() → static getDerivedStateFromProps() → render() → componentDidMount()
The constructor runs first, setting initial state. Then getDerivedStateFromProps gives you a chance to update state based on initial props. render computes the virtual DOM, and React updates the real DOM. Finally, componentDidMount fires — the component is now fully inserted, ready for side effects.
Updating Phase (props or state change): static getDerivedStateFromProps() → shouldComponentUpdate() → render() → getSnapshotBeforeUpdate() → componentDidUpdate()
When new props arrive or setState is called, getDerivedStateFromProps runs first to derive state from the new props. shouldComponentUpdate decides whether to continue (performance optimization). render computes the new virtual DOM and React updates the actual DOM. getSnapshotBeforeUpdate captures DOM values before the update (scroll positions, cursor locations). Finally, componentDidUpdate runs after the DOM is updated.
Unmounting Phase (component death): componentWillUnmount()
One method runs right before removal. This is your cleanup opportunity — timers, subscriptions, event listeners.
Error Handling Phase (when render fails): static getDerivedStateFromError() → componentDidCatch()
When an error occurs during render, getDerivedStateFromError runs first to update state (showing a fallback UI), then componentDidCatch logs the error to an external service.
╔═══════════════════════════════════════════════════════════════════════════════════╗ ║ REACT CLASS COMPONENT LIFECYCLE ║ ╠═══════════════════════════════════════════════════════════════════════════════════╣ ║ ║ ║ ┌─────────────────────────────────────────────────────────────────────────────┐ ║ ║ │ MOUNTING PHASE │ ║ ║ │ ┌──────────────┐ ┌──────────────────────┐ ┌────────┐ ┌───────────┐│ ║ ║ │ │ constructor │ -> │ getDerivedStateFrom │ -> │ render │ -> │component ││ ║ ║ │ │ │ │ Props │ │ │ │DidMount ││ ║ ║ │ └──────────────┘ └──────────────────────┘ └────────┘ └───────────┘│ ║ ║ │ │ │ │ │ │ ║ ║ │ │ (rarely used) (pure) (side │ ║ ║ │ │ │ effects) │ ║ ║ │ └──────────────────────┬─────────────────────┘ │ │ ║ ║ │ │ │ │ ║ ║ │ ▼ ▼ │ ║ ║ │ ┌─────────────────────────────────────────────────────────────────────┐ │ ║ ║ │ │ UPDATING PHASE │ │ ║ ║ │ │ │ │ ║ ║ │ │ props change OR setState() │ │ ║ ║ │ │ │ │ │ │ ║ ║ │ │ └──────────┬─────────┘ │ │ ║ ║ │ │ ▼ │ │ ║ ║ │ │ ┌──────────────────────────────────────────────────────────────┐ │ │ ║ ║ │ │ │ getDerivedStateFromProps() │ │ │ ║ ║ │ │ │ (update state from new props — rare) │ │ │ ║ ║ │ │ └────────────────────────────┬─────────────────────────────────┘ │ │ ║ ║ │ │ ▼ │ │ ║ ║ │ │ ┌──────────────────────────────────────────────────────────────┐ │ │ ║ ║ │ │ │ shouldComponentUpdate() │ │ │ ║ ║ │ │ │ (performance — return false to skip render) │ │ │ ║ ║ │ │ └────────────────────────────┬─────────────────────────────────┘ │ │ ║ ║ │ │ ▼ │ │ ║ ║ │ │ ┌──────────────────────────────────────────────────────────────┐ │ │ ║ ║ │ │ │ render() │ │ │ ║ ║ │ │ │ (compute new virtual DOM — pure) │ │ │ ║ ║ │ │ └────────────────────────────┬─────────────────────────────────┘ │ │ ║ ║ │ │ ▼ │ │ ║ ║ │ │ ┌──────────────────────────────────────────────────────────────┐ │ │ ║ ║ │ │ │ getSnapshotBeforeUpdate(prevProps, prevState) │ │ │ ║ ║ │ │ │ (capture DOM values before update — scroll position) │ │ │ ║ ║ │ │ └────────────────────────────┬─────────────────────────────────┘ │ │ ║ ║ │ │ │ │ │ ║ ║ │ │ [React updates the real DOM] │ │ ║ ║ │ │ │ │ │ ║ ║ │ │ ▼ │ │ ║ ║ │ │ ┌──────────────────────────────────────────────────────────────┐ │ │ ║ ║ │ │ │ componentDidUpdate(prevProps, prevState, snapshot) │ │ │ ║ ║ │ │ │ (side effects based on DOM changes) │ │ │ ║ ║ │ │ └──────────────────────────────────────────────────────────────┘ │ │ ║ ║ │ │ │ │ │ ║ ║ │ └───────────────────────────────┬─────────────────────────────────────┘ │ ║ ║ │ │ │ ║ ║ │ ▼ │ ║ ║ │ ┌─────────────────────────────────────────────────────────────────────┐ │ ║ ║ │ │ UNMOUNTING PHASE │ │ ║ ║ │ │ │ │ ║ ║ │ │ ┌──────────────────────────────────────┐ │ │ ║ ║ │ │ │ componentWillUnmount() │ │ │ ║ ║ │ │ │ (clean up timers, subscriptions) │ │ │ ║ ║ │ │ └──────────────────────────────────────┘ │ │ ║ ║ │ └─────────────────────────────────────────────────────────────────────┘ │ ║ ║ │ │ ║ ║ └─────────────────────────────────────────────────────────────────────────────┘ ║ ║ ║ ║ ╔═════════════════════════════════════════════════════════════════════════════╗ ║ ║ ║ ERROR HANDLING PHASE ║ ║ ║ ║ ║ ║ ║ ║ (error thrown during render or lifecycle method) ║ ║ ║ ║ │ ║ ║ ║ ║ ▼ ║ ║ ║ ║ ┌──────────────────────────────────────────────────────────────┐ ║ ║ ║ ║ │ static getDerivedStateFromError(error) │ ║ ║ ║ ║ │ (update state to show fallback UI) │ ║ ║ ║ ║ └────────────────────────────┬─────────────────────────────────┘ ║ ║ ║ ║ │ ║ ║ ║ ║ ▼ ║ ║ ║ ║ ┌──────────────────────────────────────────────────────────────┐ ║ ║ ║ ║ │ componentDidCatch(error, errorInfo) │ ║ ║ ║ ║ │ (log error to external service) │ ║ ║ ║ ║ └──────────────────────────────────────────────────────────────┘ ║ ║ ║ ╚═════════════════════════════════════════════════════════════════════════════╝ ║ ║ ║ ║ ┌─────────────────────────────────────────────────────────────────────────┐ ║ ║ │ HOOKS EQUIVALENT QUICK REFERENCE │ ║ ║ ├─────────────────────────────────────────────────────────────────────────┤ ║ ║ │ componentDidMount → useEffect(() => {}, []) │ ║ ║ │ componentDidUpdate → useEffect(() => {}, [dep]) │ ║ ║ │ componentWillUnmount → return () => {} inside useEffect │ ║ ║ │ shouldComponentUpdate → React.memo() or useMemo() │ ║ ║ │ getDerivedStateFromProps → useState + useEffect with dep, or a key │ ║ ║ │ getSnapshotBeforeUpdate → useLayoutEffect() │ ║ ║ │ componentDidCatch → No hook equivalent — use ErrorBoundary │ ║ ║ └─────────────────────────────────────────────────────────────────────────┘ ║ ║ ║ ╚═══════════════════════════════════════════════════════════════════════════════════╝
Error Handling Lifecycle Methods — componentDidCatch and getDerivedStateFromError
By default, when a JavaScript error occurs inside a React component during rendering, the entire component tree unmounts and you see a blank white screen. Error boundaries are components that catch these errors, log them, and display a fallback UI instead of crashing the whole application.
componentDidCatch is a lifecycle method that catches errors in the render phase of any component below it. It receives the error object and an info object containing the component stack trace. Use it to log errors to an external service like Sentry or LogRocket.
getDerivedStateFromError is a static method called when an error is caught. It receives the error and must return an object to update state. This is used to render a fallback UI — you set hasError: true, and your render method shows an error message instead of the crashed component tree.
The critical nuance: error boundaries only catch errors during rendering, in lifecycle methods, and in constructors of the whole tree below them. They DO NOT catch errors in event handlers, asynchronous code (setTimeout, fetch), or errors thrown in the error boundary itself. For event handlers, use try/catch and local error state instead.
Error boundaries are class components only. There is no hook equivalent — you must write a class component for an error boundary, even if the rest of your codebase uses functional components.
Place error boundaries strategically, not everywhere. Wrap major UI sections (header, main content, sidebar) so a failure in one doesn't nuke the whole page. A good pattern is one error boundary per route or per major feature module.
import React, { Component } from 'react'; // ============================================================ // ERROR BOUNDARY COMPONENT — Catches render errors // ============================================================ class ErrorBoundary extends Component { constructor(props) { super(props); this.state = { hasError: false, error: null, errorInfo: null, }; } // static method 1: Update state to show fallback UI static getDerivedStateFromError(error) { // Return an object to update state return { hasError: true }; } // instance method 2: Log the error to an external service componentDidCatch(error, errorInfo) { // Log error to your monitoring service (Sentry, LogRocket, etc.) console.error('ErrorBoundary caught an error:', error, errorInfo); this.setState({ error: error, errorInfo: errorInfo, }); // In production, send to your error tracking service // window.Sentry.captureException(error, { extra: errorInfo }); } render() { if (this.state.hasError) { // Fallback UI — show something friendly return ( <div className="error-fallback"> <h2>Something went wrong</h2> <details style={{ whiteSpace: 'pre-wrap' }}> <summary>Technical details (only in development)</summary> {this.state.error && this.state.error.toString()} <br /> {this.state.errorInfo && this.state.errorInfo.componentStack} </details> <button onClick={() => window.location.reload()}>Reload page</button> </div> ); } // No error — render children normally return this.props.children; } } // ============================================================ // COMPONENT THAT THROWS ON PURPOSE (for testing) // ============================================================ const BuggyCounter = () => { const [count, setCount] = React.useState(0); if (count === 5) { // This error will be caught by the closest error boundary throw new Error('Intentional crash at count = 5'); } return ( <div> <p>Count: {count}</p> <button onClick={() => setCount(count + 1)}>Increment</button> </div> ); }; // ============================================================ // USING THE ERROR BOUNDARY // ============================================================ const App = () => { return ( <div> <h1>My App</h1> {/* Wrap risky component in error boundary */} <ErrorBoundary> <BuggyCounter /> </ErrorBoundary> {/* This component continues working even if BuggyCounter crashes */} <div>This text remains visible if the counter crashes above</div> </div> ); }; export { ErrorBoundary, BuggyCounter, App }; // ============================================================ // WHAT ERROR BOUNDARIES DO NOT CATCH // ============================================================ // ❌ Event handlers (use try/catch + local state) // const handleClick = () => { // try { // riskyOperation(); // } catch (error) { // setHasError(true); // local error state // } // }; // // ❌ Asynchronous code (setTimeout, requestAnimationFrame) // ❌ Server-side rendering errors // ❌ Errors thrown in the error boundary itself
The Constructor is a Trap — Here's What to Actually Do in It
Most junior devs treat the constructor like a kitchen sink. They initialize state, bind methods, fetch data, set up subscriptions. Wrong on all counts.
The constructor is for exactly two things: initializing local state from props and binding event handlers. That's it. If you're doing side effects in a constructor, you're fighting React's design.
Here's the hard truth: the constructor runs before the component mounts. There's no DOM. No DOM access. No way to interact with the real UI. Data fetching here is pointless — you'll have to handle a loading state anyway. Subscriptions here will leak because you haven't setup cleanup yet.
Use it. But use it sparingly. If you're using hooks, skip it entirely — functional components don't have constructors, and that's a feature, not a bug.
// io.thecodeforge — javascript tutorial class UserProfile extends React.Component { constructor(props) { super(props); // Only do this: this.state = { user: null, loading: true }; // Never do this: // fetch('/api/users').then(...) ← Wrong! // this.interval = setInterval(...) ← Wrong! } componentDidMount() { // Do side effects here this.fetchUser(); } render() { return <div>{this.state.loading ? 'Loading...' : this.state.user.name}</div>; } } // With hooks — no constructor at all function UserProfile() { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { fetchUser() .then(data => setUser(data)) .finally(() => setLoading(false)); }, []); return <div>{loading ? 'Loading...' : user.name}</div>; }
Render is a Pure Function — Stop Breaking That Contract
The render method is the heart of every class component. It's called on mount and every update. And it must be pure — no side effects, no mutations, no state changes.
Here's why: React calls render to figure out what the DOM should look like. Then it diffs that against the previous state. If your render method does something — like fetching data, setting timers, or modifying global state — you'll get inconsistent results and debugging nightmares.
I've seen production code where render was making network requests. The app worked on fast connections. On slow ones, it broke in ways we couldn't reproduce. Took a week to find the bug.
The rule is simple: render should return a React element. That's it. No API calls, no subscriptions, no side effects. If you need to do something based on props or state changes, use componentDidUpdate or useEffect.
This is the contract React gives you. Break it, and your code will be the reason someone's pager goes off at 3 AM.
// io.thecodeforge — javascript tutorial class SearchResults extends React.Component { render() { // ❌ WRONG: Side effect inside render // fetch('/api/search?q=' + this.props.query) // .then(res => res.json()) // // ✅ CORRECT: Just return JSX return ( <div> {this.props.results.map(item => ( <ResultCard key={item.id} data={item} /> ))} </div> ); } componentDidUpdate(prevProps) { // ❌ Do API calls here or in useEffect if (prevProps.query !== this.props.query) { fetch('/api/search?q=' + this.props.query) .then(res => res.json()) .then(data => this.setState({ results: data })); } } } // With hooks function SearchResults({ query }) { const [results, setResults] = useState([]); useEffect(() => { fetch('/api/search?q=' + query) .then(res => res.json()) .then(setResults); }, [query]); return ( <div> {results.map(item => ( <ResultCard key={item.id} data={item} /> ))} </div> ); }
The Unmount You Forget Will Haunt You — Memory Leaks 101
componentWillUnmount is where components go to die. But most developers ignore it until their app leaks memory like a sieve.
Here's what happens: you mount a component, it fetches data, sets up a WebSocket connection, subscribes to a store. The component is never used again — user navigates away, closes the tab, whatever. But your subscriptions, timers, and event listeners are still running.
A memory leak is when your app holds onto references it doesn't need. A React component that's unmounted but still holds subscriptions is a classic example. Over time, your app consumes more and more memory until the browser kills it.
I've seen production apps that froze after 20 minutes of normal use because someone forgot to clear an interval in componentWillUnmount. The fix was one line of code: clearInterval(this.interval).
If you use hooks, useEffect's cleanup function handles this. That's not optional — you must return a cleanup function for every effect that creates subscriptions, timers, or event listeners.
Your app's performance depends on you remembering this. Forget, and your users will wonder why their browser tabs crash randomly.
// io.thecodeforge — javascript tutorial class RealtimeChart extends React.Component { componentDidMount() { this.ws = new WebSocket('wss://api.example.com/stream'); this.ws.onmessage = (event) => { this.setState({ data: JSON.parse(event.data) }); }; this.interval = setInterval(() => { // heartbeat this.ws.send('ping'); }, 30000); } componentWillUnmount() { // 🚨 MUST DO: Clean up everything this.ws.close(); clearInterval(this.interval); } render() { return <ChartComponent data={this.state.data} />; } } // With hooks function RealtimeChart() { const [data, setData] = useState(null); useEffect(() => { const ws = new WebSocket('wss://api.example.com/stream'); ws.onmessage = (event) => setData(JSON.parse(event.data)); const interval = setInterval(() => ws.send('ping'), 30000); // Cleanup function runs on unmount return () => { ws.close(); clearInterval(interval); }; }, []); return <ChartComponent data={data} />; } // ⚠️ Output if cleanup is missing: // WebSocket.readyState: 3 (CLOSED) // Memory keeps growing every interval tick
Why This Guide?
Most React lifecycle content teaches you what methods exist. This guide exists because knowing method names won't save you from production bugs. The real cost of lifecycle mismanagement shows up as memory leaks, stale closures, and silent data corruption. We've seen teams ship features that work locally but crash under load because componentWillUnmount wasn't cleaning up WebSocket connections. This guide prioritizes behavioral contracts over API trivia. Each section answers: what does the framework expect from me at this point, and what breaks if I violate that contract? You will learn why setState inside componentDidUpdate without a guard causes infinite loops, why mounting subscriptions without tracking cleanup leads to duplicate event handlers, and why React calls render twice in development. This isn't a reference — it's a survival manual for components that stay alive across re-renders without corrupting state or leaking resources.
// io.thecodeforge — javascript tutorial class SubscriptionManager extends React.Component { // Contract: mount -> setup; unmount -> teardown componentDidMount() { this.interval = setInterval(this.syncData, 1000); } // Violation: no cleanup = memory leak per mount+unmount cycle componentWillUnmount() { clearInterval(this.interval); } syncData = () => { console.log('fetching latest...'); }; } // The bug: missing clearInterval doubles calls after re-mount // UseEffect equivalent with cleanup solves it deterministically
Benefits of Custom Hooks
Custom hooks exist to eliminate lifecycle duplication across components. Without them, every component that needs window resize events writes the same componentDidMount handler, the same addEventListener, and the same componentWillUnmount cleanup. Custom hooks extract this triplicate lifecycle contract into a single function. They enforce the why: every effect must leave the system in the same state it found it. A useWindowSize hook returns width and height, but internally it handles mounting, updating on resize, and unmounting without memory leaks. The benefit is not code reuse — it's contract enforcement. Class components required mixins or higher-order components, which broke encapsulation. Custom hooks keep the lifecycle logic local to where state is consumed, not inherited. They also prevent stale closure bugs: hooks re-run on every render, capturing fresh props and state. Use them for any cross-cutting concern that touches DOM, subscriptions, or timers.
// io.thecodeforge — javascript tutorial import { useState, useEffect } from 'react'; function useOnlineStatus() { const [isOnline, setIsOnline] = useState(navigator.onLine); // Single contract: mount -> listen, unmount -> remove useEffect(() => { const handleOnline = () => setIsOnline(true); const handleOffline = () => setIsOnline(false); window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }; }, []); // Empty deps: only mount/unmount return isOnline; } // Usage: const isOnline = useOnlineStatus(); // No lifecycle boilerplate in consuming component
Constructor: The Gatekeeper of Component State
Class constructors run before mounting and are invoked with props. They serve as the single location to initialize local state, bind event handlers, and validate props for type consistency. Constructors are not for side effects—no API calls, no subscriptions, no mutations. The fundamental rule: call super(props) first to inherit React.Component behavior. This ensures this.props is accessible throughout the component. State initialization should be static and derived only from props using simple assignments. Any work beyond state initialization and handler binding signals a design issue—extract it to lifecycle methods or custom hooks. Constructors run once per component instance, making them ideal for setting up initial state values that don't depend on async operations.
// io.thecodeforge — javascript tutorial import React, { Component } from 'react'; class Counter extends Component { constructor(props) { super(props); this.state = { count: props.initialCount || 0 }; this.handleIncrement = this.handleIncrement.bind(this); } handleIncrement() { this.setState(prev => ({ count: prev.count + 1 })); } render() { return <button onClick={this.handleIncrement}>{this.state.count}</button>; } }
super() call, simple state initialization, and handler binding.Constructor Pitfalls: 20 Years of Bug Reports Condensed
The constructor's most dangerous trap is unintended side effects. Setting state conditionally based on props that change later will fail—the constructor only runs once. Common mistakes: calling this.setState (use direct assignment instead), performing DOM queries, starting timers, or initializing non-React state objects that mutate. Best practices demand static properties for class fields to avoid boilerplate binding. Use class property syntax (handleClick = () => {}) to eliminate constructor handler binding entirely. For props-to-state mapping, use getDerivedStateFromProps only when necessary—direct prop usage in render is cleaner. Constructor calls to external services create tight coupling that breaks testing and SSR. The modern alternative is functional components with useState, which eliminate these entire categories of defects.
// io.thecodeforge — javascript tutorial class SaferComponent extends Component { state = { theme: this.props.defaultTheme || 'light' }; handleClick = () => { this.setState({ theme: 'dark' }); }; render() { return <div onClick={this.handleClick}>{this.state.theme}</div>; } }
The 3 AM Pager: Memory Leak That Took Down a Trading Dashboard
- Every resource you create outside React's managed state must be torn down explicitly.
- componentWillUnmount is not optional — it's a contract. If you set it up in componentDidMount, tear it down there.
- The 'Can't perform a React state update on an unmounted component' warning is never a false positive. It always signals a real leak.
- In functional components, the useEffect cleanup function is called before re-run AND on unmount — use it for both scenario logic.
Add a conditional guard: if (prevProps.userId !== this.props.userId) { this.fetchData(this.props.userId); }If using hooks, verify the dependency array is correct: useEffect(() => { fetchData(userId); }, [userId]) — never omit deps for side effects.Add console log inside componentWillUnmount to verify it fires: componentWillUnmount() { console.log('Unmounting', this.constructor.name); clearInterval(this.timer); }For async cancels: const controller = new AbortController(); fetch(url, { signal: controller.signal }); controller.abort() in willUnmount.Check if shouldComponentUpdate is defined and returning false. Add console.log('SCU:', nextProps, this.props) to debug.Ensure you're not mutating state directly — setState({ ...state, key: newValue }) creates a new reference. Direct mutation ({ state.key = newValue }) won't trigger re-render or didUpdate.| Aspect | Class Lifecycle Methods | useEffect Hook (Functional) |
|---|---|---|
| Syntax | Separate named methods (componentDidMount, etc.) | Single useEffect() with config options |
| Mount once | componentDidMount() | useEffect(() => {}, []) |
| Update on change | componentDidUpdate(prevProps, prevState) | useEffect(() => {}, [dependency]) |
| Cleanup / unmount | componentWillUnmount() | return () => {} inside useEffect |
| Logic co-location | Setup and teardown split across methods | Setup and teardown in same block |
| Multiple concerns | All concerns mixed in one method | Separate useEffect per concern |
| Learning curve | Explicit and readable for beginners | More composable, but requires mental model shift |
| Legacy codebase likelihood | Very common in pre-2019 code | Standard in modern React codebases |
| shouldComponentUpdate equivalent | shouldComponentUpdate() method | React.memo() + useMemo() |
| Preventing infinite loops | Manual comparison guard required | Correct dependency array prevents most loops |
| Error boundary integration | componentDidCatch() for error handling | No direct equivalent — use ErrorBoundary component |
| File | Command / Code | Purpose |
|---|---|---|
| LifecycleOverview.jsx | class UserProfileCard extends Component { | The Three Phases Every React Component Goes Through |
| WeatherDashboard.jsx | class WeatherDashboard extends Component { | componentDidMount |
| SalesReportTable.jsx | class SalesReportTable extends Component { | componentDidUpdate |
| ChatSubscription.jsx | class ChatPanel extends Component { | componentWillUnmount |
| NotificationSubscription.jsx | class NotificationPanelClass extends Component { | Hooks vs Class Lifecycle Methods |
| RareLifecycleMethods.jsx | class UserProfileForm extends Component { | Rarely Used Lifecycle Methods |
| lifecycle-diagram.txt | ╔═══════════════════════════════════════════════════════════════════════════════... | Visual Lifecycle Diagram |
| ErrorBoundary.jsx | class ErrorBoundary extends Component { | Error Handling Lifecycle Methods |
| ConstructorPitfall.js | class UserProfile extends React.Component { | The Constructor is a Trap |
| RenderPurity.js | class SearchResults extends React.Component { | Render is a Pure Function |
| UnmountCleanup.js | class RealtimeChart extends React.Component { | The Unmount You Forget Will Haunt You |
| WhyGuideExample.js | class SubscriptionManager extends React.Component { | Why This Guide? |
| CustomHookExample.js | function useOnlineStatus() { | Benefits of Custom Hooks |
| ConstructorExample.js | class Counter extends Component { | Constructor |
| ConstructorBestPractices.js | class SaferComponent extends Component { | Constructor Pitfalls |
Key takeaways
Common mistakes to avoid
5 patternsMissing comparison guard in componentDidUpdate
Not cleaning up timers or subscriptions in componentWillUnmount
Fetching data inside render() instead of componentDidMount
render() must be a pure function with zero side effects — it only describes what the UI looks like given current state and props. If you need to fetch on mount, use componentDidMount. If you need to re-fetch on prop change, use componentDidUpdate with a comparison guard.Using an anonymous arrow function in addEventListener and trying to remove it with removeEventListener
Not using error boundaries, then wondering why a single component error crashes the whole page
Interview Questions on This Topic
Can you walk me through the React component lifecycle phases in order, and tell me which method you'd use to fetch data from an API and why?
What happens if you call setState inside componentDidUpdate without a conditional check? How would you debug that in production?
How does useEffect with a dependency array replicate componentDidUpdate — and how is it different from calling componentDidUpdate in a class component with the same prop comparison logic?
What are error boundaries in React? When would you use componentDidCatch vs getDerivedStateFromError?
How would you preserve scroll position when adding new messages to a chat component? Which lifecycle method would you use and why?
Frequently Asked Questions
The order is: constructor → render → componentDidMount. The constructor sets up initial state, render describes the UI structure, and componentDidMount fires after the component is actually inserted into the real DOM. This is why data fetching belongs in componentDidMount and not in the constructor — the DOM doesn't exist yet when the constructor runs.
No — componentDidMount fires exactly once per component instance, right after the initial mount. Subsequent re-renders (caused by state or prop changes) trigger componentDidUpdate instead. If you want code that runs on every render, you'd use componentDidUpdate, but almost always with a conditional guard to avoid infinite loops.
This warning appears when an async operation (like a fetch call) completes after the component has already been removed from the DOM, and then calls setState. The fix is to either cancel the async operation in componentWillUnmount using an AbortController (for fetch requests) or a clearTimeout/clearInterval for timers, or track a mounted flag — set this._isMounted = true in componentDidMount and check it before calling setState in your async callback.
useEffect with no dependency array at all runs after every single render — including the initial mount. This is almost never what you want and usually indicates a bug. useEffect with an empty array [] runs once after the initial mount — equivalent to componentDidMount. useEffect with a populated array [value] runs after the initial mount AND after every render where value changed — equivalent to componentDidMount + componentDidUpdate for that specific value. The cleanup function (return) runs before the next effect run and on unmount — equivalent to componentWillUnmount.
Yes, you can mark componentDidMount as async. But there's a catch: React doesn't await the async function. If the component unmounts before the async operation completes, the callback will try to call setState on an unmounted component. The fix: use an AbortController for fetch requests, or track a mounted flag (this._isMounted) that you set to false in componentWillUnmount. Check it before calling setState in the async callback.
getSnapshotBeforeUpdate is called right before React applies the DOM changes from a render. It captures values from the DOM — like scroll position, cursor location, or element dimensions — and returns them as a snapshot. That snapshot is passed to componentDidUpdate, where you can use it to restore the DOM after the update. The classic use case is preserving scroll position when adding new messages to a chat list: capture scrollHeight in getSnapshotBeforeUpdate, then restore scroll position in componentDidUpdate.
Error boundaries are class components only — there is no hook equivalent. Implement either static getDerivedStateFromError(error) to update state and show a fallback UI, componentDidCatch(error, errorInfo) to log the error, or both. Error boundaries catch errors only during rendering, in lifecycle methods, and in constructors — not in event handlers or async code. For those, use try/catch and local error state. Wrap error boundaries around major UI sections so a failure in one area doesn't crash the whole page.
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
That's React.js. Mark it forged?
13 min read · try the examples if you haven't