Saved
Frontend Pattern

Component Lifecycle

Manage setup, updates, and cleanup phases of component existence.

Difficulty Beginner

By Den Odell

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 useEffect can 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 useEffect runs asynchronously after render, which can cause issues if effects need to run synchronously - useLayoutEffect is 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.
Stay Updated

Get New Patterns
in Your Inbox

Join thousands of developers receiving regular insights on frontend architecture patterns

No spam. Unsubscribe anytime.