Component Lifecycle
Problem
Event listeners accumulate without being removed, creating dozens or hundreds of duplicate listeners that fire multiple times on each interaction. Subscriptions to data sources leak memory as components mount and unmount during navigation, gradually consuming more memory until the browser slows or crashes, while API calls fire at the wrong time or multiple times, creating race conditions where stale data from an old request overwrites fresh data from a newer request. Timers and intervals continue running after components are destroyed, executing callbacks against DOM elements that no longer exist and throwing errors, and WebSocket connections remain open after users navigate away, wasting bandwidth and server resources. Components that fetch data on mount may not refetch when critical props change, showing stale information, and without proper lifecycle management, applications become progressively slower and buggier as users navigate through the interface.
Solution
Hook into mount, update, and unmount phases to initialize resources, respond to prop or state changes, and clean up side effects when components are destroyed.
On mount, set up event listeners, start subscriptions, fetch initial data, and initialize any resources the component needs. On update, respond to changes in props or state by refetching data, updating subscriptions, or recalculating derived values.
On unmount, remove event listeners, cancel subscriptions, clear timers, close connections, and abort pending async operations. This ensures event listeners are removed, timers are cleared, subscriptions are canceled, and API requests are aborted when components are destroyed or dependencies change.
Frameworks provide lifecycle hooks or effect systems that guarantee cleanup functions run at the appropriate time.
Example
This example shows how to use lifecycle hooks to set up a data subscription on mount and clean it up on unmount to prevent memory leaks.
React useEffect with Cleanup
import { useEffect, useState } from 'react';
function DataSubscriber({ userId }) {
const [data, setData] = useState(null);
useEffect(() => {
// Subscribe to data source when component mounts or userId changes
const subscription = dataSource.subscribe(userId, newData => {
setData(newData);
});
// Cleanup function runs when component unmounts or before re-running effect
return () => {
// Clean up subscription to prevent memory leaks
subscription.unsubscribe();
};
}, [userId]); // Re-run effect when userId changes
return <div>{data ? data.name : 'Loading...'}</div>;
}
Handling Multiple Lifecycle Concerns
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
// Set up multiple resources
const subscription = dataSource.subscribe(userId, setUser);
const interval = setInterval(() => {
console.log('Polling for updates');
}, 5000);
document.addEventListener('visibilitychange', handleVisibilityChange);
// Clean up ALL resources when component unmounts or userId changes
return () => {
subscription.unsubscribe();
clearInterval(interval);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [userId]);
return <div>{user?.name}</div>;
}
Preventing Race Conditions with Abort Controllers
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
const abortController = new AbortController();
async function fetchResults() {
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: abortController.signal
});
const data = await response.json();
setResults(data);
} catch (error) {
// Ignore abort errors
if (error.name !== 'AbortError') {
console.error('Search failed:', error);
}
}
}
fetchResults();
// Abort pending request when query changes or component unmounts
return () => {
abortController.abort();
};
}, [query]);
return <ul>{results.map(r => <li key={r.id}>{r.title}</li>)}</ul>;
}
Vue Composition API Lifecycle
<script setup>
import { ref, onMounted, onUnmounted, onBeforeUnmount, watch } from 'vue';
const props = defineProps(['userId']);
const data = ref(null);
let subscription = null;
// Called when component is added to the DOM
onMounted(() => {
// Subscribe to data source and store reference for cleanup
subscription = dataSource.subscribe(props.userId, newData => {
data.value = newData;
});
});
// Called just before component is removed
onBeforeUnmount(() => {
console.log('About to unmount, saving state...');
});
// Called when component is removed from the DOM
onUnmounted(() => {
// Clean up subscription to prevent memory leaks
if (subscription) {
subscription.unsubscribe();
}
});
// Watch for prop changes and react accordingly
watch(() => props.userId, (newId, oldId) => {
// Unsubscribe from old user
if (subscription) {
subscription.unsubscribe();
}
// Subscribe to new user
subscription = dataSource.subscribe(newId, newData => {
data.value = newData;
});
});
</script>
Vue Options API Lifecycle
<script>
export default {
props: ['userId'],
data() {
return {
subscription: null,
data: null
};
},
// Called when component is added to the DOM
mounted() {
this.subscription = dataSource.subscribe(this.userId, data => {
this.data = data;
});
},
// Called when props change
updated() {
// Component has re-rendered with new props
},
// Called just before component is removed
beforeUnmount() {
// Opportunity to save state before unmounting
},
// Called when component is removed from the DOM
unmounted() {
if (this.subscription) {
this.subscription.unsubscribe();
}
},
// Watch for specific prop changes
watch: {
userId(newId, oldId) {
// Re-subscribe when userId changes
if (this.subscription) {
this.subscription.unsubscribe();
}
this.subscription = dataSource.subscribe(newId, data => {
this.data = data;
});
}
}
};
</script>
Svelte Reactive Lifecycle
<script>
import { onMount, onDestroy, beforeUpdate, afterUpdate } from 'svelte';
export let userId;
let subscription;
let data;
// Called when component is added to the DOM
onMount(() => {
subscription = dataSource.subscribe(userId, newData => {
data = newData;
});
// Return cleanup function (alternative to onDestroy)
return () => {
subscription.unsubscribe();
};
});
// Called before component updates
beforeUpdate(() => {
console.log('About to update');
});
// Called after component updates
afterUpdate(() => {
console.log('DOM has been updated');
});
// React to prop changes with reactive statements
$: {
// Re-subscribe when userId changes
if (subscription) {
subscription.unsubscribe();
}
if (userId) {
subscription = dataSource.subscribe(userId, newData => {
data = newData;
});
}
}
// Called when component is removed from the DOM
onDestroy(() => {
if (subscription) {
subscription.unsubscribe();
}
});
</script>
Web Components Full Lifecycle
class DataSubscriber extends HTMLElement {
constructor() {
super();
this.subscription = null;
this.attachShadow({ mode: 'open' });
}
// Called when element is added to the DOM
connectedCallback() {
const userId = this.getAttribute('user-id');
this.subscription = dataSource.subscribe(userId, data => {
this.render(data);
});
}
// Called when element is removed from the DOM
disconnectedCallback() {
// Clean up subscription to prevent memory leaks
if (this.subscription) {
this.subscription.unsubscribe();
}
}
// Called when observed attributes change
static get observedAttributes() {
return ['user-id'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'user-id' && oldValue !== newValue) {
// Re-subscribe when user-id attribute changes
if (this.subscription) {
this.subscription.unsubscribe();
}
this.subscription = dataSource.subscribe(newValue, data => {
this.render(data);
});
}
}
// Called when element is moved to a new document
adoptedCallback() {
console.log('Element adopted by new document');
}
render(data) {
this.shadowRoot.innerHTML = `<div>${data.name}</div>`;
}
}
customElements.define('data-subscriber', DataSubscriber);
Timer Management
function PollingComponent() {
const [data, setData] = useState(null);
useEffect(() => {
// Fetch immediately on mount
fetchData();
// Set up polling interval
const intervalId = setInterval(() => {
fetchData();
}, 5000);
// Clear interval on unmount
return () => {
clearInterval(intervalId);
};
}, []);
async function fetchData() {
const response = await fetch('/api/data');
setData(await response.json());
}
return <div>{data?.value}</div>;
}
Conditional Lifecycle Logic
function ConditionalEffect({ shouldSubscribe, userId }) {
useEffect(() => {
// Only set up subscription if condition is met
if (!shouldSubscribe) {
return; // No cleanup needed if we didn't subscribe
}
const subscription = dataSource.subscribe(userId, setData);
return () => {
subscription.unsubscribe();
};
}, [shouldSubscribe, userId]);
return <div>{/* component content */}</div>;
}
Benefits
- Prevents memory leaks by ensuring proper cleanup of subscriptions, event listeners, timers, and other resources when components are destroyed.
- Enables components to respond appropriately to mount, update, and unmount events, creating predictable behavior across the component lifecycle.
- Provides clear, framework-supported hooks for initializing and tearing down resources at the right time.
- Prevents race conditions by cleaning up stale async operations and aborting pending requests when dependencies change or components unmount.
- Makes component behavior predictable and testable by centralizing side effect management in lifecycle hooks.
- Allows components to react to prop or state changes by refetching data or updating subscriptions when dependencies change.
- Reduces debugging time by making side effects explicit and traceable through lifecycle hook usage.
Tradeoffs
- Easy to forget cleanup functions, leading to subtle memory leaks that compound over time and are difficult to diagnose without memory profiling tools.
- Complex dependency arrays in React’s
useEffectcan make hooks hard to understand - determining the correct dependencies requires deep understanding of closure and reference equality. - Excessive lifecycle logic scattered across multiple hooks or lifecycle methods can make components harder to reason about and maintain.
- May trigger unnecessary re-renders if dependencies are not managed carefully - including objects or arrays in dependency arrays can cause infinite loops if they’re recreated on every render.
- Debugging lifecycle issues can be challenging in complex component trees where multiple nested components have interdependent side effects.
- React’s
useEffectruns asynchronously after render, which can cause issues if effects need to run synchronously -useLayoutEffectis available but creates layout thrashing if overused. - Cleanup functions must be pure and synchronous - they cannot return promises or use
async/await, requiring careful handling of async cleanup operations. - Different frameworks handle lifecycle hooks differently - React runs effects after render, Vue calls lifecycle hooks synchronously, and Web Components have their own lifecycle timing, making cross-framework patterns difficult.
- The dependency array in React is a common source of bugs - React’s exhaustive-deps ESLint rule helps but can require complex workarounds for legitimate cases where dependencies should be omitted.
- Components that manage many resources may have long, complex cleanup functions that become difficult to maintain and test thoroughly.
- Lifecycle hooks can create implicit ordering dependencies - if hook A depends on hook B running first, this relationship is not enforced and can break during refactoring.
- Testing lifecycle behavior requires careful setup and teardown in test suites to avoid test pollution where effects from one test affect subsequent tests.