This skill covers subsystems in Svelte 5 that are "non-Runes" but still part of the core reactive architecture: lifecycle hooks (onMount / onDestroy / tick)、Stores(writable / readable / derived / custom), Context API (createContext / setContext / getContext), and the testing system (Vitest unit and component testing, Storybook, Playwright e2e). It also covers the browser support matrix and exceptions.
When to use this skill
- Need to access the DOM after the component mounts (
onMount) or clean up before the component is destroyed (onDestroy) - Need to wait for DOM updates after a state change (
tick) - Need to share reactive state across components, but do not want to use a
importmodule (use Stores or Context) - Need to write cross-component asynchronous flows (Readable/derived store)
- Need to use Context to replace prop drilling, and when SSR is involved, do not pollute the global scope
- Need to write Vitest unit/component tests, Storybook stories, or Playwright e2e for Svelte components
- Need to check minimum browser versions and exceptional features
Critical: onMount
onMount(fn) Runs immediately after the component is mounted to the DOM.Runs only in the browser—not called during SSR.
Basic usage
<script>
import { onMount } from 'svelte';
onMount(() => {
console.log('mounted');
});
</script>
Return a cleanup function
<script>
import { onMount } from 'svelte';
onMount(() => {
const interval = setInterval(() => console.log('beep'), 1000);
return () => clearInterval(interval);
});
</script>
Key constraints:
onMountmust receive asynchronous functionto correctly return cleanup.async () => {}always returnsPromise; cleanup will not be called on unmount.
Calling from an external module
onMount does not have to be written at the top level of a component script—it must only be called during component initialization. Calling it from a helper function in the same module is allowed.
Critical: onDestroy
onDestroy(fn) Before the component is destroyed,immediatelyexecutes.It also runs in SSR components—this is the only one of the four lifecycle hooks that executes on the server side.
<script>
import { onDestroy } from 'svelte';
onDestroy(() => {
console.log('destroyed');
});
</script>
Common uses: clean up setInterval, cancel fetch AbortController, unbind global event listeners.
Critical: tick
tick() returns Promise, resolves after all pending state changes have been applied to the DOM; if there are no pending changes, it resolves in the next microtask.
<script>
import { tick } from 'svelte';
async function handle() {
count = count + 1;
await tick();
// 此时 DOM 已更新
element.scrollIntoView();
}
</script>
Common scenarios: focus, scroll, measure DOM, call third-party imperative APIs after a state change.
Deprecated: beforeUpdate / afterUpdate
The "before and after the whole component updates" hook from the Svelte 4 era. In Svelte 5,it is shimmed but unavailable in runes components, so use instead:
| Svelte 4 | Svelte 5 |
|---|---|
beforeUpdate(() => {}) |
$effect.pre(() => {}) |
afterUpdate(() => {}) |
$effect(() => {}) |
The Runes version triggers only when explicitly read state changes, and is more precise. For example, a chat window only scrolls to the bottom when messages changes, and theme switching will not reset the scroll position.
Critical: Stores (writable, readable, derived)
A Store is an object that satisfies the "store contract": it must have subscribe(fn) → unsubscribe; a writable store must also have set(value)。
<script>
import { writable } from 'svelte/store';
const count = writable(0);
$count; // 自动订阅,读取当前值
count.set(1); // 写入
$count = 2; // 语法糖:等价于 count.set(2)
</script>
writable
import { writable } from 'svelte/store';
const count = writable(0, () => {
// 第一个订阅者订阅时调用
return () => {
// 最后一个订阅者退订时调用
};
});
The second argument is the start/stop function, commonly used to establish external connections (WebSocket, timers).
readable
A store that cannot be set externally; initial value + start function:
import { readable } from 'svelte/store';
const time = readable(new Date(), (set) => {
set(new Date());
const id = setInterval(() => set(new Date()), 1000);
return () => clearInterval(id);
});
derived
Derive from one or more stores:
import { derived, writable } from 'svelte/store';
const a = writable(1);
const b = writable(2);
const sum = derived([a, b], ([$a, $b]) => $a + $b);
Asynchronous version: accepts (values, set, update), allows calling set/updatein the async callback; can return a cleanup function; can pass a third argument as the initial value.
readonly / get
readonly(store): wraps as a read-only view (withoutset/update)get(store): reads once synchronously (internally subscribes → reads → unsubscribes,Not recommended for hot paths)
When to use stores vs $state
Svelte 5 recommends prioritizing runes ($state、.svelte.js module):
| Scenario | Recommendation |
|---|---|
| Extracting reusable logic | .svelte.js file + $state |
| Cross-component shared state | Module-level $state object |
| Complex asynchronous data flow | Stores(readable + timers/subscriptions) |
| Interop with RxJS | Stores($ auto-subscription) |
| Simple cross-component counter | Either works |
Simple rule: if you can use runes, use runes; use stores for "event streams/observable sequences".
Critical: Context API
Context lets a parent component pass values to descendant components at any depth without prop drilling.
createContext (recommended, Svelte 5.40+)
// context.ts
import { createContext } from 'svelte';
interface User { name: string; }
export const [getUser, setUser] = createContext<User>();
<!-- Parent.svelte -->
<script>
import { setUser } from './context';
setUser({ name: 'world' });
</script>
<!-- Child.svelte -->
<script>
import { getUser } from './context';
const user = getUser();
</script>
<h1>hello {user.name}</h1>
setContext / getContext (alternative)
<!-- Parent -->
<script>
import { setContext } from 'svelte';
setContext('my-key', value);
</script>
<!-- Child -->
<script>
import { getContext } from 'svelte';
const value = getContext('my-key');
</script>
Keys and values can be any JS value.createContext provides type safety and requires no key.
hasContext / getAllContexts
Check whether a certain key exists in the current component context hierarchy;getAllContexts() returns a Map of all current contexts. Often used in library internals.
Combining with state
Set the $state object into context,do not reassign—otherwise the reactive link is broken:
<!-- 错误 -->
<button onclick={() => counter = { count: 0 } }>reset</button>
<!-- 正确:原地修改 -->
<button onclick={() => counter.count = 0}>reset</button>
Svelte will emit a warning.
Replacing global state (critical for SSR)
Module-level $state is shared across requests under SSR (data leakage). Context is request-isolated, soprefer Context when user-specific data is involved。
Critical: Testing
Vitest unit testing
// counter.svelte.test.js
import { flushSync } from 'svelte';
import { expect, test } from 'vitest';
test('Counter', () => {
let count = $state(0);
count = 1;
flushSync();
expect(count).toBe(1);
});
Component testing
vite.config.js:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: { environment: 'jsdom' },
resolve: process.env.VITEST ? { conditions: ['browser'] } : undefined
});
import { mount, unmount, flushSync } from 'svelte';
import { expect, test } from 'vitest';
import Component from './Component.svelte';
test('Component', () => {
const c = mount(Component, { target: document.body, props: { n: 0 } });
expect(document.body.innerHTML).toBe('<button>0</button>');
document.body.querySelector('button').click();
flushSync();
expect(document.body.innerHTML).toBe('<button>1</button>');
unmount(c);
});
$effect does not run automatically during mount ; use flushSync() in tests to force synchronous triggering.
Testing with $effect
import { flushSync } from 'svelte';
import { expect, test } from 'vitest';
test('Effect', () => {
const cleanup = $effect.root(() => {
let count = $state(0);
let log = [];
$effect(() => log.push(count));
flushSync();
expect(log).toEqual([0]);
count = 1;
flushSync();
expect(log).toEqual([0, 1]);
});
cleanup();
});
Storybook
Configured via npx sv add storybook ; use the play function to simulate user interactions and assert:
<Story name="Filled" play={async ({ canvas, userEvent }) => {
await userEvent.type(canvas.getByTestId('email'), 'a@b.com');
await userEvent.click(canvas.getByRole('button'));
await expect(canvas.getByText("You're in!")).toBeInTheDocument();
}} />
Playwright e2e
// tests/home.spec.js
import { expect, test } from '@playwright/test';
test('h1 visible', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toBeVisible();
});
playwright.config.js configure webServer to start the preview server.
Critical: Browser support
Baseline target 2020 (Baseline 2020):
| Browser | Minimum version |
|---|---|
| Chrome/Edge | 87 |
| Firefox | 83 |
| Safari | 14 |
| Opera | 73 |
| Opera (Android) | 62 |
| Samsung Internet | 14.0 |
| Android WebView | 87 |
| Internet Explorer | Not supported |
Exceptional features (require a higher version)
| Feature | Chrome/Edge | Firefox | Safari |
|---|---|---|---|
$state.snapshot |
98 | 94 | 15.4 |
bind:devicePixelContentBoxSize |
— | 93 | Not supported |
flip from svelte/animate |
— | 126 | — |
Quick Fixes
- onMount cleanup does not execute → the function is
async; use a synchronous function and internallyawait。 - store reference in UI does not update → must use the
$storeprefix to read; do not destructure (const { subscribe } = storedoes not auto-subscribe). - derived does not trigger → no store value is read in the callback; or the array form
derived([a,b], ([$a,$b]) => ...)。 - Context leaks between SSR → module-level
$stateis globally shared; usesetContext。 - In Vitest
document is not defined→test.environment: 'jsdom'; or add at the top of the file// @vitest-environment jsdom。 - State does not take effect after component mount → call
flushSync()。 - Playwright cannot find the element → check whether
webServer.commandstarted correctly; increasetimeout。
Gotchas
- store must be declared at the top level of the component: it cannot be inside an
ifblock or function. - Do not add the
$prefix to non-store variables— will be recognized as store. - onMount is the only "after mount", there is no "after render"; use
tick()to wait for the DOM. - onMount does not run during SSR—accessing
window/documentmust be placed in onMount or onDestroy (onDestroy runs during SSR). - setContext/getContext must be called synchronously, during the setup phase (cannot be inside an event handler).
- If the Context value is a primitive value (number/string), changes do not trigger updates—use
$statean object or a function getter. $state.snapshotBrowsers require 98+ / Safari 15.4+—check when releasing for compatibility.- Order of cleanups bound to onMount: when onMount unmounts, cleanup is called first, then onDestroy.
FAQ
Q: Does Svelte 5 still need stores?
A: For simple state use the $state + .svelte.js module; stores still have value for complex async flows, timers, external subscriptions, and RxJS interoperability.
Q: onMount vs $effect, how to choose?
A: Use onMount when you need to access DOM nodes or start side effects (fetch, timers, binding window events); use $effect。
Q: Why doesn't the cleanup of an async function in onMount execute?
A: async () => {} always returns Promise;onMount skips cleanup when it receives a non-function. Switch to wrapping the async logic in a synchronous function:
onMount(() => {
let cancelled = false;
(async () => {
const data = await fetch(...);
if (!cancelled) state = data;
})();
return () => { cancelled = true; };
});
Q: When should Context replace props?
A: When a value needs to pass through 3+ layers of intermediate components, or when the parent component does not directly know the child component (such as {@render children()}).
Q: What is the difference between Vitest unit tests and component tests?
A: Unit tests pure logic (runes in .svelte.js files), no DOM; component tests use jsdom to render the full Svelte component.
Q: Can Svelte 5 still be used on IE 11? A: Not supported. Minimum Chrome 87 / Firefox 83 / Safari 14.
Q: Is it safe to use $state.snapshot in SSR projects?
A: The server side is Node, with no browser version requirements; only check the exception table when using it on the client.
Q: Do I need to build before running tests with Playwright?
A: playwright.config.js with webServer: { command: 'npm run build && npm run preview', port: 4173 }configured, Playwright automatically starts the preview server.
Examples
| File | Content |
|---|---|
examples/lifecycle-hooks.md |
onMount cleanup, onDestroy, tick DOM measurement, deprecated hooks |
examples/stores-advanced.md |
writable/readable/derived/custom store, async flows |
examples/store-patterns.md |
stores vs $state, persistence, cross-component patterns |
examples/context-advanced.md |
createContext, setContext, composing with state, SSR safety |
examples/testing-vitest.md |
unit/component tests, $effect.root, flushSync, context wrapper |
examples/testing-playwright.md |
e2e tests, Playwright config |
References
| File | Content |
|---|---|
references/lifecycle-hooks-reference.md |
onMount/onDestroy/tick full signatures, SSR considerations |
references/stores-api-reference.md |
all store APIs, TypeScript types, contracts |
references/context-api-reference.md |
createContext/setContext/getContext/hasContext, SSR behavior |
references/testing-reference.md |
full Vitest/Storybook/Playwright setup |
references/browser-support.md |
browser matrix, exception features, polyfill guide |