Component Lifecycle
Manage setup, updates, and cleanup phases of component existence.
In this article
Problem
If you navigate around your application for a while and check memory usage, you’ll often find it still climbing. The usual causes are event listeners added but never removed and subscriptions opened but never closed, often for components that no longer exist.
I’ve seen applications where navigating 50 pages created 500 event listeners because components kept adding listeners without ever removing them. Click handlers fired three times per click, and memory grew until the browser became unusable.
Race conditions are another symptom: request A starts, request B starts for newer data, but A finishes first and overwrites the fresher data with stale information. Without proper lifecycle management, you can’t abort the stale request before it overwrites the newer data.
Solution
Whatever your component sets up needs undoing at the right point in its lifecycle.
On mount, a component sets up event listeners, starts subscriptions, fetches initial data, and initializes external resources. On unmount, it reverses all of that by removing listeners, canceling subscriptions, clearing timers, and aborting pending requests. Without that cleanup, the application leaks memory.
When props change, the component cleans up resources tied to the old values and sets up new ones. If the user ID changes, it unsubscribes from the old user’s data stream and subscribes to the new one.
Modern frameworks provide lifecycle hooks for this: React’s useEffect with cleanup functions, Vue’s onMounted and onUnmounted, Svelte’s $effect with a returned cleanup function. These hooks run at the right moments for you, which is easier and less error-prone than tracking the lifecycle manually.
Example
These examples show lifecycle management across frameworks, from basic cleanup to aborting stale requests.
Basic Lifecycle with Cleanup
import { useEffect, useState } from 'react';
function DataSubscriber({ userId }) {
const [data, setData] = useState(null);
useEffect(() => {
const subscription = dataSource.subscribe(userId, setData);
// Cleanup runs on unmount or before re-running effect
return () => subscription.unsubscribe();
}, [userId]);
return <div>{data ? data.name : 'Loading...'}</div>;
} <script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue';
const props = defineProps(['userId']);
const data = ref(null);
let subscription = null;
onMounted(() => {
subscription = dataSource.subscribe(props.userId, newData => {
data.value = newData;
});
});
onUnmounted(() => {
if (subscription) subscription.unsubscribe();
});
watch(() => props.userId, (newId) => {
if (subscription) subscription.unsubscribe();
subscription = dataSource.subscribe(newId, newData => {
data.value = newData;
});
});
</script> <script>
let { userId } = $props();
let data = $state();
// Runs on mount and again whenever userId changes;
// the returned cleanup runs before each rerun and on destroy
$effect(() => {
const subscription = dataSource.subscribe(userId, newData => {
data = newData;
});
return () => subscription.unsubscribe();
});
</script> class DataSubscriber extends HTMLElement {
constructor() {
super();
this.subscription = null;
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.subscribe(this.getAttribute('user-id'));
}
disconnectedCallback() {
if (this.subscription) this.subscription.unsubscribe();
this.subscription = null;
}
static get observedAttributes() { return ['user-id']; }
attributeChangedCallback(name, oldValue, newValue) {
// This also runs before connectedCallback for the initial attribute,
// so only resubscribe once the element is in the document
if (name === 'user-id' && oldValue !== newValue && this.isConnected) {
this.subscribe(newValue);
}
}
subscribe(userId) {
if (this.subscription) this.subscription.unsubscribe();
this.subscription = dataSource.subscribe(userId, data => this.render(data));
}
render(data) {
const div = document.createElement('div');
div.textContent = data.name;
this.shadowRoot.replaceChildren(div);
}
}
customElements.define('data-subscriber', DataSubscriber); Handling Multiple Lifecycle Concerns
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const subscription = dataSource.subscribe(userId, setUser);
const interval = setInterval(() => console.log('Polling'), 5000);
const handleVisibilityChange = () => console.log('Visibility:', document.visibilityState);
document.addEventListener('visibilitychange', handleVisibilityChange);
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 controller = new AbortController();
fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal })
.then(res => res.json())
.then(setResults)
.catch(err => {
if (err.name !== 'AbortError') console.error(err);
});
return () => controller.abort();
}, [query]);
return <ul>{results.map(r => <li key={r.id}>{r.title}</li>)}</ul>;
}
Conditional Effects
function ConditionalEffect({ shouldSubscribe, userId }) {
const [data, setData] = useState(null);
useEffect(() => {
if (!shouldSubscribe) return;
const subscription = dataSource.subscribe(userId, setData);
return () => subscription.unsubscribe();
}, [shouldSubscribe, userId]);
return <div>{data ? data.name : 'Not subscribed'}</div>;
}
Benefits
- Proper cleanup stops memory leaks, so the application doesn’t keep using more memory the longer someone uses it.
- Aborting stale requests when dependencies change stops old data from overwriting newer data.
- Behavior becomes predictable because effects run at well-defined lifecycle points rather than at arbitrary moments.
- External resources get managed properly: WebSocket connections close and event listeners get removed.
- Components respond correctly to prop changes. When the user ID changes, the component unsubscribes from the old data and subscribes to the new.
- Debugging becomes easier with side effects centralized in lifecycle hooks instead of spread across the component.
Tradeoffs
- You can easily forget a cleanup function, and the resulting memory leaks are hard to diagnose because they don’t cause immediate errors.
- React’s dependency arrays are tricky: mistakes lead to stale closures with outdated values or infinite loops crashing the app.
- Once one of your components has five or more effects, it becomes difficult to reason about and maintain.
- React’s useEffect runs after browser paint. useLayoutEffect runs synchronously before paint, but it delays the paint until it finishes, so it’s best kept for the cases that need it.
- Cleanup functions must be synchronous. You can’t return a promise, so async cleanup needs different handling.
- Frameworks have different timing semantics: React’s useEffect usually runs after the browser paints, Vue’s onMounted runs as soon as the component’s DOM has been inserted, before paint, and a custom element’s callbacks run as soon as it’s added to or removed from the document.
- When you test lifecycle behavior, you need careful setup and teardown, because effects from one test can leak into another.
Summary
Component lifecycle defines the stages every component passes through: mounting, updating, and unmounting. Understanding these phases lets you pair each resource’s setup with a matching cleanup, whether the component is going away or its props have changed. Proper lifecycle management prevents memory leaks and ensures predictable behavior as users navigate your application.