Svelte State Management
Choose the right state scope in SvelteKit: component-local runes, context API for subtree isolation, and module-level state for true singletons
When to Use
- You need to decide whether state belongs in a component, a context, or a module-level singleton
- You are hitting SSR data leakage bugs caused by shared module-level state between requests
- You need to share state across a component subtree without prop-drilling or global stores
- You are implementing a typed context pattern (ContextKey) for large applications
Instructions
Layer 1 — component-local state (runes):
- For state used only within a single component, use
$statedirectly. This is always the starting point:
<script lang="ts">
let count = $state(0)
let open = $state(false)
</script>
Layer 2 — reactive class / .svelte.ts for shared reactive logic:
- Extract shared logic into a
.svelte.tsfile using$stateand$derived. This works in Svelte 5 and is preferred over Svelte 4 stores:
// lib/counter.svelte.ts
export class Counter {
count = $state(0);
doubled = $derived(this.count * 2);
increment() {
this.count++;
}
reset() {
this.count = 0;
}
}
// Singleton (app-wide):
export const counter = new Counter();
// Or factory (per-use):
export function createCounter() {
return new Counter();
}
Layer 3 — context API for subtree isolation:
- Use
setContext/getContextto share state within a component tree without prop-drilling. Context is scoped to the component that callssetContextand all its descendants:
<!-- FormRoot.svelte — sets context -->
<script lang="ts">
import { setContext } from 'svelte'
const form = $state({ values: {}, errors: {} })
setContext('form', form)
</script>
<slot />
<!-- FormField.svelte — reads context -->
<script lang="ts">
import { getContext } from 'svelte'
const form = getContext<FormState>('form')
</script>
- Use a typed symbol key (ContextKey pattern) to prevent key collisions in large apps:
// lib/form-context.ts
import { getContext, setContext } from 'svelte';
const FORM_KEY = Symbol('form');
export function setFormContext(state: FormState) {
setContext(FORM_KEY, state);
}
export function getFormContext(): FormState {
const ctx = getContext<FormState>(FORM_KEY);
if (!ctx) throw new Error('getFormContext must be called inside a FormRoot component');
return ctx;
}
Layer 4 — module-level singletons (client-only):
- Module-level state (outside any function or class) persists for the lifetime of the browser tab — useful for truly global singletons like auth state or feature flags:
// lib/auth.svelte.ts
let user = $state<User | null>(null);
export function getUser() {
return user;
}
export function setUser(u: User | null) {
user = u;
}
- NEVER use module-level state for per-user or per-request data in SSR — it leaks between requests on the server:
// WRONG — server-side singleton shared between ALL users:
let currentUser = $state<User | null>(null); // data leak!
// CORRECT — use locals (set in hooks.server.ts) for per-request server state
// CORRECT — use context API for per-component-tree state
SvelteKit page data as state source:
- Page data from load functions is the canonical source for server-fetched state. Do not duplicate it into local stores:
<script lang="ts">
import type { PageData } from './$types'
let { data }: { data: PageData } = $props()
// data.user is already reactive — no need to copy to $state
// Just use data.user directly in the template
</script>
Details
State management decision tree:
Is the state used in only one component?
→ Yes → $state (rune)
Does it need to be shared across a component subtree?
→ Yes → Context API (setContext/getContext)
Does it need to be shared app-wide (no component boundary)?
→ Client-only? → Module-level $state in .svelte.ts
→ SSR-safe? → Server: locals / load data; Client: stores or context
Does it come from the server?
→ Use load functions + page data (not stores)
Why stores cause SSR issues:
Svelte stores are module-level singletons. In Node.js, modules are shared across all simultaneous requests. If a user's data is written to a store on request A, it can be visible to request B's render if B starts before A finishes. The fix: use event.locals in hooks.server.ts, passed through load functions.
Context API and SSR:
Context is per-render-tree on the server (each SSR request creates a fresh component tree), so it is SSR-safe. This makes it the right choice for per-request data in layouts and pages.
Derived state from page data:
If you need computed values based on page data, use $derived rather than storing derived values:
<script lang="ts">
let { data } = $props()
const totalPrice = $derived(
data.cartItems.reduce((sum, item) => sum + item.price, 0)
)
</script>
Persisting state across navigations:
SvelteKit re-runs load functions on navigation, replacing data. For client-side persistence across navigations, use module-level state or the browser's sessionStorage/localStorage.
Source
https://kit.svelte.dev/docs/state-management
Process
- Read the instructions and examples in this document.
- Apply the patterns to your implementation, adapting to your specific context.
- Verify your implementation against the details and edge cases listed above.
Harness Integration
- Type: knowledge — this skill is a reference document, not a procedural workflow.
- No tools or state — consumed as context by other skills and agents.
Success Criteria
- The patterns described in this document are applied correctly in the implementation.
- Edge cases and anti-patterns listed in this document are avoided.