Svelte 5 changes how we think about state
Svelte 5 introduced runes—a new way to declare reactive state that feels more like plain JavaScript. At the same time, the older stores API is still alive and well. Choosing between stores vs runes: a Svelte & SvelteKit guide for developers depends on your use case, and most projects will use both. We'll walk through when to reach for runes, when stores still shine, how context fits in, and the patterns that keep shared state clean across your application.
Runes in .svelte.ts files — Shared reactive state outside components
Runes are Svelte's answer to "how do I share reactive state without wrapping everything in components?" They let you declare reactive variables with $state, $derived, $effect, and friends directly in TypeScript files. This is a game-changer for server-side logic, utility modules, and keeping concerns separated.
The beauty of runes is that they work outside the component tree. Create a .svelte.ts file (the extension matters—it signals to Svelte's compiler that runes are allowed), export your reactive state, and import it wherever you need it. The reactivity flows automatically.
// lib/stores/cart.svelte.ts
export interface Item {
id: string;
name: string;
quantity: number;
price: number;
}
export const cart = $state({
items: [] as Item[],
discountCode: null as string | null,
});
export function addToCart(item: Item) {
const existing = cart.items.find((i) => i.id === item.id);
if (existing) {
existing.quantity += item.quantity;
} else {
cart.items.push(item);
}
}
export function removeFromCart(itemId: string) {
cart.items = cart.items.filter((i) => i.id !== itemId);
}
export const cartTotal = $derived.by(() => {
return cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
});
export const itemCount = $derived(cart.items.length);Now, in any component, you import and use it directly:
// routes/cart/+page.svelte
<script>
import { cart, addToCart, removeFromCart, cartTotal, itemCount } from '$lib/stores/cart.svelte.js';
</script>
<h1>Cart ({itemCount})</h1>
<ul>
{#each cart.items as item (item.id)}
<li>
{item.name} × {item.quantity} — ${item.price}
<button onclick={() => removeFromCart(item.id)}>Remove</button>
</li>
{/each}
</ul>
<p><strong>Total: ${cartTotal}</strong></p>This approach is clean, type-safe, and integrates seamlessly with SvelteKit's load functions. Because runes are reactive at the JavaScript level, changes automatically propagate to all subscribers without extra subscription logic.
Stores — writable and readable still work
The writable and readable stores from svelte/store are not going away. They remain powerful, especially when you need fine-grained control over subscription lifecycle or when integrating with libraries that expect observable-like interfaces.
// lib/stores/auth.ts
import { writable } from 'svelte/store';
export interface AuthState {
user: { id: string; email: string } | null;
loading: boolean;
error: string | null;
}
function createAuthStore() {
const { subscribe, set, update } = writable<AuthState>({
user: null,
loading: false,
error: null,
});
async function login(email: string, password: string) {
update((state) => ({ ...state, loading: true }));
try {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
const user = await response.json();
set({ user, loading: false, error: null });
} catch (err) {
update((state) => ({
...state,
loading: false,
error: err instanceof Error ? err.message : 'Login failed',
}));
}
}
async function logout() {
await fetch('/api/logout', { method: 'POST' });
set({ user: null, loading: false, error: null });
}
return { subscribe, login, logout };
}
export const auth = createAuthStore();In a component, you'd subscribe with the auto-subscription syntax:
// routes/profile/+page.svelte
<script>
import { auth } from '$lib/stores/auth';
</script>
{#if $auth.loading}
<p>Loading...</p>
{:else if $auth.user}
<p>Welcome, {$auth.user.email}!</p>
<button onclick={() => auth.logout()}>Logout</button>
{:else}
<p>Not logged in.</p>
{/if}The $ prefix automatically unsubscribes when the component unmounts. Stores work great for state that needs a custom factory function, methods, or filtering logic.
When to use stores — Interop with libraries and streams
Reach for stores when you're integrating with external libraries that expect subscriptions or observables, or when you're working with WebSocket streams, polling intervals, or other continuous data sources.
Consider a real-time notification system built on top of a library like RxJS or a custom event emitter:
// lib/stores/notifications.ts
import { writable } from 'svelte/store';
import { eventBus } from '$lib/eventBus'; // hypothetical event emitter
export interface Notification {
id: string;
message: string;
type: 'success' | 'error' | 'info';
timestamp: number;
}
function createNotificationStore() {
const { subscribe, update } = writable<Notification[]>([]);
eventBus.on('notification', (payload) => {
const notification: Notification = {
id: crypto.randomUUID(),
...payload,
timestamp: Date.now(),
};
update((notifications) => [...notifications, notification]);
// Auto-remove after 5 seconds
setTimeout(() => {
update((notifications) => notifications.filter((n) => n.id !== notification.id));
}, 5000);
});
return { subscribe };
}
export const notifications = createNotificationStore();Stores handle the subscription plumbing for you, making it easy to wire up external events without littering your components with onMount cleanup logic.
Context — setContext and getContext for scoped state
Context is perfect for state that belongs to a specific subtree of your component hierarchy. Use setContext to provide values and getContext to consume them. This avoids prop drilling and keeps parent-child relationships clear.
A modal or accordion is a classic example. The parent component (modal wrapper) provides state, and child components (modal header, body, footer) consume it:
// lib/components/Modal.svelte
<script>
import { setContext } from 'svelte';
export let isOpen = false;
setContext('modal', {
isOpen: () => isOpen,
close: () => {
isOpen = false;
},
});
</script>
{#if isOpen}
<div class="modal-backdrop">
<div class="modal-content">
<slot />
</div>
</div>
{/if}// lib/components/ModalClose.svelte
<script>
import { getContext } from 'svelte';
const modal = getContext('modal');
</script>
<button onclick={() => modal.close()}>
<slot />
</button>Context works seamlessly with both runes and stores. You can even store a rune object in context:
// lib/components/FormProvider.svelte
<script>
import { setContext } from 'svelte';
let formState = $state({
values: {},
errors: {},
});
setContext('form', formState);
</script>
<slot />Context keeps state scoped and prevents top-level bloat. It's especially useful in multi-page forms, nested UI components, and theme providers.
Common mistakes
- Mixing rune and store patterns inconsistently. Pick one primary pattern per feature. If you need a cart, decide: are you using
$statein a .svelte.ts file or a writable store? Mixing both creates confusion and makes the code harder to follow. - Forgetting the .svelte.ts extension. Runes only work in files with the
.svelteor.svelte.tsextension. Regular.tsfiles won't have the rune compiler hooks active, and you'll get confusing errors. - Over-using context for global state. Context is tree-scoped. If you need state across unrelated parts of your app (sidebar, main view, footer), use a store or rune instead. Context passed through many levels is a sign to reconsider your architecture.
- Mutating store values without update. With writable stores, always use
updateorsetto change values. Direct mutation won't notify subscribers. Runes, by contrast, are mutated directly—that's their design. - Ignoring cleanup in stores. If your store subscribes to external events or opens WebSocket connections, always provide an unsubscribe function or cleanup logic. Memory leaks hide in unmanaged subscriptions.
Key takeaways
- Runes (via
$state,$derived) are the new default for shared reactive state in Svelte 5. Use them in.svelte.tsfiles for clean, type-safe state outside components. - Stores remain valuable for integration with external libraries, custom factory logic, and cases where you need explicit subscription control.
- Context is your answer to scoped state within a component subtree—use it to avoid prop drilling and keep hierarchies clear.
- A typical SvelteKit app will use all three: runes for most business logic, stores for library interop and streams, and context for component-tree-specific state.
- Be consistent within a feature. Don't mix rune and store patterns for the same piece of state—it confuses teammates and future you.






Comments
Loading comments…