Skip to content

Lifecycle

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.

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. If fn returns 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:

src/lib/OrderProbe.svelte
<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>
src/lib/order.test.ts
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 $effect
flowchart LR
  create["component created"] --> mount["onMount (browser, once)"]
  mount --> effects["$effect runs after DOM updates"]
  effects --> effects
  effects --> destroy["onDestroy + effect cleanups"]
Component lifecycle moments

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.

src/lib/FlushDemo.svelte
<script lang="ts">
import { onMount } from 'svelte';
let { log }: { log: string[] } = $props();
onMount(() => { log.push('onMount'); });
</script>
<p>ok</p>
src/lib/flush.test.ts
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 it

The 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.

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.

src/lib/CleanupDemo.svelte
<script lang="ts">
let { log }: { log: string[] } = $props();
$effect(() => {
log.push('effect-run');
return () => log.push('effect-cleanup');
});
</script>
<p>ok</p>
src/lib/cleanup.test.ts
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 unmounts

untrack: 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:

src/lib/UntrackDemo.svelte
<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>
src/lib/untrack.test.ts
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 dependency

untrack 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”
src/lib/Flaky.svelte
<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>
src/lib/BoundaryHost.svelte
<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>
src/lib/boundary.test.ts
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 child

shouldThrow 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.)

reset() cannot be called synchronously inside onerror, while the boundary is still mid-handling the error — Svelte throws a real error if you try:

src/lib/ResetSyncHost.svelte
<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>
src/lib/resetsync.test.ts
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 throws

The 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.

src/lib/PendingDemo.svelte
<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>
src/lib/pending.test.ts
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 it

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).

What you getBenefitCost
onMount over a bare $effectStates “browser-only, once” intent clearly; guaranteed order before the first $effectCannot re-run — wrong tool for anything dependency-driven
<svelte:boundary failed>One broken widget doesn’t unmount the whole appYou 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 contentExperimental (experimental.async); only covers the first resolution, not later updates ($effect.pending() is for that)
untrack inside an effectRead a value without over-subscribing the effectEasy to misuse — untracking a value that should trigger a re-run silently breaks reactivity
  • “onMount and the first $effect run in the order I’d guess, or at the same time.” Proven above: onMount’s callback always runs before any $effect body on first render.
  • “mount() finishes once onMount has run.” It doesn’t — mount()/hydrate() return before effects run at all; flushSync() is what forces them to catch up, proven above.
  • “Calling reset() inside onerror immediately un-breaks the boundary.” Not synchronously — Svelte throws svelte_boundary_reset_onerror if you try, proven above. Fix the cause elsewhere (or await 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.

When reviewing generated lifecycle code, check for:

  • Is a value that should be recalculated whenever a prop/state changes computed inside onMount instead of $derived? onMount runs exactly once — it goes stale the moment the source value changes.
  • Does an $effect read a value only to log or measure it once, without wrapping that read in untrack, causing needless re-runs whenever that unrelated value changes?
  • Is reset() called synchronously inside onerror (a real, thrown error, proven above) instead of from a separate action (button, retry-after-fix) or behind await tick()?
  • Does code call mount()/hydrate() and then immediately assert on onMount side effects (DOM changes, injected elements) without a flushSync() in between?
  • Is a <svelte:boundary> added with no failed/onerror at 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).

In Svelte 5, what covers most former lifecycle-hook use cases?
On first render, in what order do these run?
Right after mount() returns, has onMount run yet?
const app = mount(App, { target });
console.log(onMountRan); // ?
What does untrack(() => b) do inside an $effect?
What happens here?
<svelte:boundary onerror={(error, reset) => {
  fixTheError();
  reset(); // called synchronously
}}>
What does the pending snippet on <svelte:boundary> show?