4 min read
On this page

Component Lifecycle

Svelte components have a lifecycle: they mount into the DOM, update when state changes, and eventually get destroyed. In Svelte 5, $effect is the primary tool for hooking into lifecycle events. The older onMount and onDestroy functions still work and have specific uses. Understanding when each runs helps you avoid bugs with subscriptions, intervals, and DOM manipulation.

$effect for Mounting & Cleanup

$effect runs after the component mounts and after every subsequent update where its dependencies change. The cleanup function runs before re-execution and when the component is destroyed.

<script>
  let count = $state(0);

  $effect(() => {
    console.log('Component is in the DOM, count is:', count);

    return () => {
      console.log('Cleaning up for count:', count);
    };
  });
</script>

<button onclick={() => count++}>{count}</button>
// On mount:
Component is in the DOM, count is: 0

// After clicking:
Cleaning up for count: 0
Component is in the DOM, count is: 1

// On destroy:
Cleaning up for count: 1

This single pattern covers most lifecycle needs: setup and teardown for subscriptions, timers, event listeners, and external library integrations.

$effect for DOM Measurement

Since $effect runs after the DOM updates, it is safe to measure elements:

<script>
  let content = $state('Short text');
  let height = $state(0);
  let container;

  $effect(() => {
    // Runs after DOM update, safe to measure
    // Reading `content` here creates a dependency
    void content;
    height = container.getBoundingClientRect().height;
  });
</script>

<div bind:this={container}>
  <p>{content}</p>
</div>
<p>Container height: {height}px</p>
<button onclick={() => content += '\nAnother line of text'}>Add line</button>

$effect with No Dependencies

If your effect does not read any reactive values, it runs once on mount and the cleanup runs on destroy. This is equivalent to onMount with a cleanup return:

<script>
  $effect(() => {
    const handler = (e) => console.log('Key pressed:', e.key);
    document.addEventListener('keydown', handler);

    return () => document.removeEventListener('keydown', handler);
  });
</script>

onMount: Browser-Only Code

onMount is a lifecycle function that runs once when the component first mounts to the DOM. Its key property: it only runs in the browser, never during server-side rendering.

<script>
  import { onMount } from 'svelte';

  let canvas;
  let ctx;

  onMount(() => {
    ctx = canvas.getContext('2d');
    ctx.fillStyle = '#ff3e00';
    ctx.fillRect(10, 10, 100, 100);

    return () => {
      // Optional cleanup, runs on destroy
      ctx = null;
    };
  });
</script>

<canvas bind:this={canvas} width="200" height="200"></canvas>

When to Use onMount vs $effect

Use onMount when:

  • You need code that runs only in the browser (not during SSR).
  • You are initializing a third-party library that requires DOM access.
  • The setup is a one-time operation that does not depend on reactive state.

Use $effect when:

  • The code should re-run when reactive state changes.
  • You want automatic dependency tracking.
  • You need cleanup that re-runs between updates, not just on destroy.
<script>
  import { onMount } from 'svelte';

  let map;
  let mapContainer;
  let center = $state({ lat: 40.7128, lng: -74.006 });

  // One-time setup: browser-only, no reactive dependencies
  onMount(() => {
    map = new MapLibrary(mapContainer, { center, zoom: 12 });
    return () => map.destroy();
  });

  // Reactive: re-run when center changes
  $effect(() => {
    if (map) {
      map.setCenter(center.lat, center.lng);
    }
  });
</script>

<div bind:this={mapContainer} class="map"></div>

onDestroy: Cleanup Without Effects

onDestroy runs when the component is removed from the DOM. Unlike onMount, it runs during SSR as well (useful for cleanup of server-side state).

<script>
  import { onDestroy } from 'svelte';

  const interval = setInterval(() => {
    console.log('tick');
  }, 1000);

  onDestroy(() => {
    clearInterval(interval);
  });
</script>

In practice, $effect's cleanup return handles most destruction needs. onDestroy is mainly useful when:

  • You set up something outside of an $effect or onMount that needs cleanup.
  • You need cleanup logic that runs during SSR (rare but possible).

The Difference Between $effect & onMount

Aspect $effect onMount
Runs on mount Yes Yes
Re-runs on state change Yes (tracked dependencies) No
Runs during SSR No No
Cleanup timing Before re-run and on destroy On destroy only
Dependency tracking Automatic None

The practical distinction: onMount is "do this once when the component appears." $effect is "do this whenever relevant state changes, starting from when the component appears."

<script>
  import { onMount } from 'svelte';

  let data = $state(null);
  let filter = $state('all');

  // Runs once: fetch initial data
  onMount(async () => {
    const response = await fetch('/api/items');
    data = await response.json();
  });

  // Runs whenever filter changes: log analytics
  $effect(() => {
    if (data) {
      console.log(`User filtered by: ${filter}, ${data.length} items`);
    }
  });
</script>

Real-World Lifecycle Patterns

WebSocket Connection

<script lang="ts">
  import { onMount } from 'svelte';

  let messages = $state<string[]>([]);
  let connected = $state(false);
  let ws: WebSocket;

  onMount(() => {
    ws = new WebSocket('wss://example.com/chat');

    ws.onopen = () => { connected = true; };
    ws.onclose = () => { connected = false; };
    ws.onmessage = (event) => {
      messages.push(event.data);
    };

    return () => ws.close();
  });

  function send(text: string) {
    if (ws && connected) ws.send(text);
  }
</script>

Intersection Observer

<script>
  let element;
  let isVisible = $state(false);

  $effect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => { isVisible = entry.isIntersecting; },
      { threshold: 0.5 }
    );

    observer.observe(element);
    return () => observer.disconnect();
  });
</script>

<div bind:this={element} class:visible={isVisible}>
  {isVisible ? 'In view' : 'Out of view'}
</div>

Debounced Input with Cleanup

<script>
  let search = $state('');
  let results = $state([]);

  $effect(() => {
    if (search.length < 2) {
      results = [];
      return;
    }

    const timeout = setTimeout(async () => {
      const response = await fetch(`/api/search?q=${encodeURIComponent(search)}`);
      results = await response.json();
    }, 300);

    return () => clearTimeout(timeout);
  });
</script>

<input bind:value={search} placeholder="Search...">
<ul>
  {#each results as result}
    <li>{result.name}</li>
  {/each}
</ul>

Each keystroke clears the previous timeout (via cleanup) and starts a new one. The fetch only fires after 300ms of inactivity.

LocalStorage Persistence

<script>
  import { onMount } from 'svelte';

  let theme = $state('light');

  onMount(() => {
    const saved = localStorage.getItem('theme');
    if (saved) theme = saved;
  });

  $effect(() => {
    localStorage.setItem('theme', theme);
  });
</script>

<select bind:value={theme}>
  <option value="light">Light</option>
  <option value="dark">Dark</option>
</select>

onMount reads the saved value once (browser-only, so it does not break SSR). $effect persists changes whenever theme updates.

Keep Components Simple

Lifecycle hooks are powerful, but overusing them is a code smell. If a component has five effects, it is probably doing too much. Consider:

  • Extracting logic into .svelte.ts files: Reactive logic with effects can live in reusable modules.
  • Splitting large components: A component with many lifecycle concerns should probably be multiple components.
  • Using derivedinsteadofderived instead of effect: If you are computing a value, not performing a side effect, $derived is the right tool.
// src/lib/use-window-size.svelte.ts
export function useWindowSize() {
  let width = $state(0);
  let height = $state(0);

  $effect(() => {
    function update() {
      width = window.innerWidth;
      height = window.innerHeight;
    }
    update();
    window.addEventListener('resize', update);
    return () => window.removeEventListener('resize', update);
  });

  return {
    get width() { return width; },
    get height() { return height; }
  };
}
<script>
  import { useWindowSize } from '$lib/use-window-size.svelte';
  const size = useWindowSize();
</script>

<p>Window: {size.width} x {size.height}</p>

Common Pitfalls

  • Using onMount for reactive logic: If you need code to re-run when state changes, use $effect, not onMount with manual subscriptions.
  • Forgetting cleanup: Every setInterval, addEventListener, subscribe, or external library initialization needs a corresponding cleanup. Without it, you leak memory and create ghost listeners.
  • Accessing the DOM before mount: The DOM is not available during SSR or before onMount/$effect runs. Guard browser-specific code behind onMount or check typeof window !== 'undefined'.
  • Async onMount without error handling: onMount(async () => { ... }) swallows errors silently. Add try/catch or handle the promise rejection.
  • Too many effects in one component: If you have more than two or three effects, the component is likely doing too much. Extract logic into reusable modules or split into smaller components.

Key Takeaways

  • $effect is the primary lifecycle tool in Svelte 5. It handles mount, update, and cleanup in one pattern.
  • onMount runs once in the browser only. Use it for one-time setup that requires DOM access.
  • onDestroy runs on cleanup. In most cases, $effect's return function is sufficient instead.
  • Always clean up subscriptions, intervals, and event listeners. The cleanup function in $effect runs before re-execution and on component destruction.
  • Extract reusable lifecycle logic into .svelte.ts files to keep components focused and simple.
  • If you are computing a value rather than performing a side effect, use $derived instead of $effect.