Lifecycle
$effect covers most lifecycle needs
Section titled “$effect covers most lifecycle needs”In Svelte 4, you reached for lifecycle hooks (onMount, beforeUpdate, afterUpdate) for anything involving the DOM or timing. In Svelte 5, $effect covers most of that: it runs after the DOM updates, re-runs when its dependencies change, and its returned cleanup function handles teardown.
<script> let count = $state(0);
$effect(() => { document.title = `Count: ${count}`; // runs after mount and on every change return () => { document.title = 'App'; }; // cleanup on destroy / before re-run });</script>So before reaching for a lifecycle function, ask whether an effect fits. Often it does.
onMount and onDestroy
Section titled “onMount and onDestroy”Two lifecycle functions remain genuinely useful:
onMount(fn)runs once after the component is first rendered to the DOM, only in the browser (never during SSR). It’s the right place for browser-only setup: measuring the DOM, initializing a canvas or map library, starting an interval. Iffnreturns a function, it’s called on destroy.onDestroy(fn)runs when the component is removed — for cleanup that isn’t tied to a specific effect (unsubscribing, clearing timers).
<script> import { onMount, onDestroy } from 'svelte';
onMount(() => { const chart = new Chart(canvasEl); // browser-only library return () => chart.destroy(); // cleanup on unmount });
onDestroy(() => console.log('gone'));</script>The key distinction from $effect: onMount runs exactly once and only in the browser, which is precisely what you want for one-time, browser-only initialization. An effect that reads no reactive state also runs once, but onMount states that intent clearly and guarantees browser-only execution.
Proving the order: onMount runs before the first $effect
Section titled “Proving the order: onMount runs before the first $effect”Both fire once on first render, but not in the order you might guess. onMount’s callback runs, then $effect bodies run:
<script lang="ts"> import { onMount } from 'svelte'; let { log }: { log: string[] } = $props();
log.push('script-top');
onMount(() => { log.push('onMount'); });
$effect(() => { log.push('effect'); });</script>
<p>ok</p>import { test, expect } from 'vitest';import { render } from '@testing-library/svelte';import OrderProbe from './OrderProbe.svelte';
test('order on first render: script body, then onMount, then $effect', () => { const log: string[] = []; render(OrderProbe, { props: { log } }); expect(log).toEqual(['script-top', 'onMount', 'effect']);});✓ order on first render: script body, then onMount, then $effectflowchart LR create["component created"] --> mount["onMount (browser, once)"] mount --> effects["$effect runs after DOM updates"] effects --> effects effects --> destroy["onDestroy + effect cleanups"]
tick: waiting for the DOM to update
Section titled “tick: waiting for the DOM to update”Svelte batches DOM updates. When you change state and need the DOM to reflect it before your next line runs (to measure an element, or focus a freshly-shown input), await tick() resolves once pending changes are applied.
<script> import { tick } from 'svelte';
async function addAndScroll() { items.push(newItem); await tick(); // wait for the new row to be in the DOM list.scrollTop = list.scrollHeight; }</script>flushSync: forcing effects to run synchronously
Section titled “flushSync: forcing effects to run synchronously”mount()/hydrate() (the imperative APIs — used, for example, to bootstrap src/main.ts in a non-SvelteKit Svelte app) are not synchronous with respect to effects: they return before any $effect — including the ones backing onMount — has run. Effects are scheduled as a microtask. flushSync() (imported from 'svelte') forces every pending effect to run immediately — useful in tests, and any time imperative code needs the DOM/effects to be caught up right now.
<script lang="ts"> import { onMount } from 'svelte'; let { log }: { log: string[] } = $props(); onMount(() => { log.push('onMount'); });</script><p>ok</p>import { test, expect } from 'vitest';import { mount, flushSync, unmount } from 'svelte';import FlushDemo from './FlushDemo.svelte';
test('mount() does not run onMount synchronously; flushSync() forces it', () => { const target = document.createElement('div'); const log: string[] = []; const app = mount(FlushDemo, { target, props: { log } }); // mount() has already returned, but the onMount effect hasn't run yet. expect(log).toEqual([]); flushSync(); expect(log).toEqual(['onMount']); unmount(app);});✓ mount() does not run onMount synchronously; flushSync() forces itThe Svelte 5 migration guide says it plainly: “things like onMount won’t have been called by the time the [mount] function returns… If you need that guarantee, call flushSync.” Component tests written with @testing-library/svelte don’t normally need this — its render() already flushes — but raw mount()/hydrate() calls do.
$effect cleanup on unmount
Section titled “$effect cleanup on unmount”An effect’s returned cleanup function runs before the effect re-runs, and again when the component that owns it is destroyed — the same function handles both cases.
<script lang="ts"> let { log }: { log: string[] } = $props(); $effect(() => { log.push('effect-run'); return () => log.push('effect-cleanup'); });</script><p>ok</p>import { test, expect } from 'vitest';import { render } from '@testing-library/svelte';import { flushSync } from 'svelte';import CleanupDemo from './CleanupDemo.svelte';
test('$effect cleanup runs when the component unmounts', () => { const log: string[] = []; const { unmount } = render(CleanupDemo, { props: { log } }); flushSync(); expect(log).toEqual(['effect-run']); unmount(); expect(log).toEqual(['effect-run', 'effect-cleanup']);});✓ $effect cleanup runs when the component unmountsuntrack: reading a value without depending on it
Section titled “untrack: reading a value without depending on it”$effect depends on every reactive value it reads synchronously. untrack(fn) (imported from 'svelte') runs fn while suspending dependency-tracking, so a value read inside it is used but doesn’t cause the effect to re-run when it changes:
<script lang="ts"> import { untrack } from 'svelte'; let { log }: { log: string[] } = $props(); let a = $state(1); let b = $state(10);
$effect(() => { // `a` is a tracked dependency; `b` is read through untrack and is not. log.push(`a=${a} b=${untrack(() => b)}`); });</script><button data-testid="bump-a" onclick={() => a++}>bump a</button><button data-testid="bump-b" onclick={() => b++}>bump b</button>import { test, expect } from 'vitest';import { render, screen, fireEvent } from '@testing-library/svelte';import UntrackDemo from './UntrackDemo.svelte';
test('untrack exempts a value from being an effect dependency', async () => { const log: string[] = []; render(UntrackDemo, { props: { log } }); expect(log).toEqual(['a=1 b=10']);
// Changing `b` (read through untrack) must NOT re-run the effect. await fireEvent.click(screen.getByTestId('bump-b')); expect(log).toEqual(['a=1 b=10']);
// Changing `a` (tracked normally) DOES re-run it, reading the latest `b`. await fireEvent.click(screen.getByTestId('bump-a')); expect(log).toEqual(['a=1 b=10', 'a=2 b=11']);});✓ untrack exempts a value from being an effect dependencyuntrack is most useful inside lifecycle-ish effects that need to read a value for a one-time action (say, logging the current props on mount) without turning every future change of that value into a re-run.
<svelte:boundary>: containing render/effect errors and pending async content
Section titled “<svelte:boundary>: containing render/effect errors and pending async content”By default, an error thrown while rendering (or in an effect) unmounts the entire surrounding component tree — one broken widget can blank the whole page. <svelte:boundary> contains that failure to its own subtree, and separately can show a pending snippet while await expressions inside it resolve.
failed + reset(): containing a render error
Section titled “failed + reset(): containing a render error”<script lang="ts"> let { shouldThrow, events }: { shouldThrow: boolean; events: string[] } = $props(); if (shouldThrow) { throw new Error('boom'); } events.push('rendered-ok');</script><p data-testid="child-ok">ok</p><script lang="ts"> import Flaky from './Flaky.svelte'; let { shouldThrow, events }: { shouldThrow: boolean; events: string[] } = $props();</script>
<svelte:boundary onerror={() => events.push('onerror')}> <Flaky {shouldThrow} {events} />
{#snippet failed(error: unknown, reset: () => void)} <p data-testid="fallback">{(error as Error).message}</p> <button data-testid="reset-btn" onclick={() => { shouldThrow = false; reset(); }}>retry</button> {/snippet}</svelte:boundary>import { test, expect } from 'vitest';import { render, screen, fireEvent } from '@testing-library/svelte';import BoundaryHost from './BoundaryHost.svelte';
test('the boundary contains the error, and reset() re-renders the child', async () => { const events: string[] = []; render(BoundaryHost, { props: { shouldThrow: true, events } }); // The throw in Flaky never reached the top of the app — it's contained here. expect(screen.getByTestId('fallback').textContent).toBe('boom'); expect(events).toEqual(['onerror']);
await fireEvent.click(screen.getByTestId('reset-btn')); expect(screen.getByTestId('child-ok')).toBeTruthy(); expect(events).toEqual(['onerror', 'rendered-ok']);});✓ the boundary contains the error, and reset() re-renders the childshouldThrow here is a plain prop the host flips before calling reset() — that’s the pattern: fix whatever caused the error, then call reset() to have the boundary try rendering its children again from scratch. (svelte-check flags Flaky’s top-level if (shouldThrow) with a benign state_referenced_locally warning — it’s correctly noticing the check only runs once, on initial render, which is exactly the point: deciding whether this render attempt throws.)
The reset() timing gotcha
Section titled “The reset() timing gotcha”reset() cannot be called synchronously inside onerror, while the boundary is still mid-handling the error — Svelte throws a real error if you try:
<script lang="ts"> import Flaky from './Flaky.svelte'; let shouldThrow = $state(true); let events: string[] = [];</script><svelte:boundary onerror={(error, reset) => { shouldThrow = false; reset(); // BUG: called synchronously inside onerror}}> <Flaky {shouldThrow} {events} /></svelte:boundary>import { test, expect } from 'vitest';import { render } from '@testing-library/svelte';import ResetSyncHost from './ResetSyncHost.svelte';
test('calling reset() synchronously inside onerror throws', () => { expect(() => render(ResetSyncHost)).toThrowError(/svelte_boundary_reset_onerror/);});✓ calling reset() synchronously inside onerror throwsThe real error: svelte_boundary_reset_onerror — “A <svelte:boundary> reset function cannot be called while an error is still being handled” (svelte.dev/e/svelte_boundary_reset_onerror). If you can fix the error inside onerror itself, the fix is to await tick() before calling reset(), so the boundary has settled first — otherwise, resolve the cause somewhere else (a button click, as above) and call reset() from there.
pending: showing UI while awaited content resolves
Section titled “pending: showing UI while awaited content resolves”Paired with an await expression directly in the template (async Svelte — see the reactivity module’s async lesson — requires compilerOptions.experimental.async: true), the boundary’s pending snippet shows until every await inside it resolves for the first time. It does not reappear for later updates — that’s what $effect.pending() is for.
<script lang="ts"> async function loadMessage() { await new Promise((r) => setTimeout(r, 20)); return 'loaded!'; }</script>
<svelte:boundary> <p data-testid="msg">{await loadMessage()}</p>
{#snippet pending()} <p data-testid="loading">loading…</p> {/snippet}</svelte:boundary>import { test, expect } from 'vitest';import { render, screen, waitFor } from '@testing-library/svelte';import PendingDemo from './PendingDemo.svelte';
test('the pending snippet shows first, then the resolved content replaces it', async () => { render(PendingDemo); expect(screen.getByTestId('loading')).toBeTruthy(); await waitFor(() => expect(screen.getByTestId('msg').textContent).toBe('loaded!')); expect(screen.queryByTestId('loading')).toBeNull();});✓ the pending snippet shows first, then the resolved content replaces itMental Model
Section titled “Mental Model”Think of a component’s lifetime as three phases: setup (the <script> body runs top to bottom, once), mounted (onMount fires, then effects start running and re-running as their dependencies change), and teardown (effect cleanups run, then onDestroy). <svelte:boundary> doesn’t change this — it just draws a wall around a subtree so that a failure inside doesn’t propagate teardown to everything outside the wall. flushSync and tick are both about the same underlying fact: DOM updates and effects are scheduled, not immediate — tick waits for one round of them from inside a running component, flushSync forces every pending one to happen right now, useful from outside normal component code (tests, imperative mount() callers).
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
onMount over a bare $effect | States “browser-only, once” intent clearly; guaranteed order before the first $effect | Cannot re-run — wrong tool for anything dependency-driven |
<svelte:boundary failed> | One broken widget doesn’t unmount the whole app | You must design a real recovery path — reset() just retries, it doesn’t fix the underlying cause |
<svelte:boundary pending> | Declarative loading UI for awaited template content | Experimental (experimental.async); only covers the first resolution, not later updates ($effect.pending() is for that) |
untrack inside an effect | Read a value without over-subscribing the effect | Easy to misuse — untracking a value that should trigger a re-run silently breaks reactivity |
Common Misconceptions
Section titled “Common Misconceptions”- “
onMountand the first$effectrun in the order I’d guess, or at the same time.” Proven above:onMount’s callback always runs before any$effectbody on first render. - “
mount()finishes onceonMounthas run.” It doesn’t —mount()/hydrate()return before effects run at all;flushSync()is what forces them to catch up, proven above. - “Calling
reset()insideonerrorimmediately un-breaks the boundary.” Not synchronously — Svelte throwssvelte_boundary_reset_onerrorif you try, proven above. Fix the cause elsewhere (orawait tick()first) and reset from there. - “A
<svelte:boundary pending>snippet re-shows every time an awaited value inside updates.” It only shows for the first resolution; subsequent async updates need$effect.pending()instead.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated lifecycle code, check for:
- Is a value that should be recalculated whenever a prop/state changes computed inside
onMountinstead of$derived?onMountruns exactly once — it goes stale the moment the source value changes. - Does an
$effectread a value only to log or measure it once, without wrapping that read inuntrack, causing needless re-runs whenever that unrelated value changes? - Is
reset()called synchronously insideonerror(a real, thrown error, proven above) instead of from a separate action (button, retry-after-fix) or behindawait tick()? - Does code call
mount()/hydrate()and then immediately assert ononMountside effects (DOM changes, injected elements) without aflushSync()in between? - Is a
<svelte:boundary>added with nofailed/onerrorat all — silently containing errors with zero visibility, the same blind spot boundaries are supposed to fix?
🐛 Spot the bug
<script>import { onMount } from 'svelte';
let { userId } = $props();let displayName = $state('');
onMount(() => { displayName = formatName(userId); // computed once, on mount});</script>
<p>{displayName}</p>Show the answer
displayName is computed once, inside onMount — but userId is a prop, and props change over the component’s lifetime (the parent can pass a different userId later). Because this computation runs on mount only, displayName silently goes stale the moment userId changes; nothing re-runs it. The fix is let displayName = $derived(formatName(userId)); — a derived recomputes automatically whenever userId changes, which is exactly the “value computed from other reactive state” case onMount is never the right tool for (proven above: onMount runs exactly once).