Svelte 5 alternatives to stores
Aug 22, 2026 SvelteKit
Svelte 4 used a store when more than one component needed the same value. You imported writable from svelte/store, then wrote $count in the template. Svelte subscribed for you.
Svelte 5 uses $state for that. $state is a rune, a compiler keyword, not a store. The $ in $state is rune syntax. The $ in $count is a store subscribe.
writable still exists. Use it when you need start and stop tied to subscriber count. RxJS is that kind of stream. A cart or a theme is $state.
The SvelteKit tutorial keeps a search string in $state on one page. A cart badge in the nav is the same value seen by two files. We will put a Cart class in a .svelte.ts module and pass one instance through context.
We will end up with these files:
src/lib/
├── cart.svelte.ts
└── cart-context.ts
src/routes/
├── +layout.svelte
├── +page.svelte
└── cart/
└── +page.svelte Table of Contents
Step 1: create the project
If you already have an app from the tutorial or the routing post, reuse it. Otherwise install Node 18 or newer and run the Svelte CLI:
npx sv create cart
cd cart
npm install
npm run dev Pick TypeScript. Skip the add-ons.
Open http://localhost:5173. Vite is the dev server.
This post uses Svelte 5 runes and SvelteKit 2. If sv create gave you SvelteKit 3, $lib may be #lib. The SvelteKit 3 migration guide lists those renames.
Step 2: $state instead of writable
The old one-component counter looked like this:
<script lang="ts">
import { writable } from 'svelte/store';
const count = writable(0);
</script>
<button onclick={() => ($count += 1)}>
clicks {$count}
</button> $count += 1 calls count.set. The store had to sit at the top level of the component. A $ prefix on a local that was not a store was a bug.
The Svelte 5 version is a number:
<script lang="ts">
let count = $state(0);
</script>
<button onclick={() => count++}>
clicks {count}
</button> No import. No $count. You assign count like any other variable, and the template updates.
Skip saving the counter. The shop below is the app.
Step 3: a cart class in .svelte.ts
Runes work outside .svelte files if the module ends in .svelte.ts. You cannot export let count = $state(0) and reassign count from another file. The compiler rewrites each file on its own. The importer would see an object, not a number. Export an object you mutate, or a class.
Create src/lib/cart.svelte.ts:
export class Cart {
items = $state<{ id: string; name: string; price: number; qty: number }[]>([]);
count = $derived(this.items.reduce((n, item) => n + item.qty, 0));
total = $derived(this.items.reduce((n, item) => n + item.price * item.qty, 0));
add(product: { id: string; name: string; price: number }) {
const existing = this.items.find((item) => item.id === product.id);
if (existing) {
existing.qty += 1;
} else {
this.items.push({ ...product, qty: 1 });
}
}
clear() {
this.items = [];
}
} $derived replaces derived() from svelte/store. It recalculates when items changes. Svelte tracks the items, count, and total fields. The Cart instance itself is not a proxy.
Replace src/routes/+page.svelte with a shop that holds one Cart:
<script lang="ts">
import { Cart } from '$lib/cart.svelte';
const cart = new Cart();
const products = [
{ id: 'claw-hammer', name: 'Claw hammer', price: 18 },
{ id: 'masking-tape', name: 'Masking tape', price: 4 }
];
</script>
<h1>Shop</h1>
<p>Items {cart.count}</p>
<ul>
{#each products as product (product.id)}
<li>
{product.name} {product.price}
<button onclick={() => cart.add(product)}>Add</button>
</li>
{/each}
</ul> Click Add. The count next to the heading should go up. Same two products as the routing shop, without those extra routes.
The cart is gone when you navigate away. The nav cannot see it yet.
Step 4: context, not a global
The tempting move is export const cart = new Cart() in cart.svelte.ts and import it everywhere. In the browser that works. On a SvelteKit server it is one object for the whole process. If you write into it while rendering Alice’s HTML, Bob can get Alice’s cart. The state management page warns about this. Context is per component tree. On the server that means per request.
Create src/lib/cart-context.ts:
import { createContext } from 'svelte';
import type { Cart } from './cart.svelte';
export const [getCart, setCart] = createContext<Cart>(); createContext landed in Svelte 5.40. On an older 5.x, use setContext and getContext with your own key.
Replace src/routes/+layout.svelte. Construct the cart here, once per tree, then setCart so children can getCart.
<script lang="ts">
import { Cart } from '$lib/cart.svelte';
import { setCart } from '$lib/cart-context';
let { children } = $props();
const cart = new Cart();
setCart(cart);
</script>
<nav>
<a href="/">Shop</a>
<a href="/cart">Cart ({cart.count})</a>
</nav>
{@render children()} SvelteKit keeps the layout mounted when you click between / and /cart, so this Cart instance survives the navigation.
setCart has to run while the layout is initialising, not in onMount. onMount does not run on the server, and children would call getCart before you set anything.
Replace src/routes/+page.svelte so it reads the layout’s cart:
<script lang="ts">
import { getCart } from '$lib/cart-context';
const cart = getCart();
const products = [
{ id: 'claw-hammer', name: 'Claw hammer', price: 18 },
{ id: 'masking-tape', name: 'Masking tape', price: 4 }
];
</script>
<h1>Shop</h1>
<ul>
{#each products as product (product.id)}
<li>
{product.name} {product.price}
<button onclick={() => cart.add(product)}>Add</button>
</li>
{/each}
</ul> Add a hammer. The nav count should update without a full reload.
Do not call cart.add from a load function. load runs on the server. Writing into a module there shares that write with every visitor. Return the data instead, the same way the tutorial returns links. A logged-in user belongs in a session cookie. See HttpOnly cookie auth.
Step 5: a cart page
Create src/routes/cart/+page.svelte:
<script lang="ts">
import { getCart } from '$lib/cart-context';
const cart = getCart();
</script>
<h1>Cart</h1>
{#if cart.count === 0}
<p>Empty. <a href="/">Shop</a></p>
{:else}
<ul>
{#each cart.items as item (item.id)}
<li>{item.name} x{item.qty}</li>
{/each}
</ul>
<p>Total {cart.total}</p>
<button onclick={() => cart.clear()}>Clear</button>
{/if} Add two tape rolls, open /cart, then Clear. The nav should go back to 0.
If you SPA-protect a checkout later, the cart in memory is still not a source of truth. The server has to price the order again.
When a store is still the right tool
readable runs a start function when the first subscriber arrives, and a stop function when the last one leaves. That is a good fit for a clock or a WebSocket. RxJS observables fit here too. $state has no subscriber count.
import { readable } from 'svelte/store';
export const now = readable(new Date(), (set) => {
const id = setInterval(() => set(new Date()), 1000);
return () => clearInterval(id);
}); In a component, { $now } still auto-subscribes. toStore and fromStore convert between runes and the store contract when a library only speaks one of them.
SvelteKit’s own page, navigating, and updated used to be stores in $app/stores. In SvelteKit 2.12+ they are $app/state. The routing error page already reads page.status that way. SvelteKit 3 removes $app/stores entirely.
Things that trip people up
export let count = $state(0) from a .svelte.ts file will not stay a number in the importer. Export an object you mutate, or construct the class in the layout.
A module-level export const cart = new Cart() is one cart for every visitor on the server. Context is the fix if you use SSR. If you turned SSR off and will never turn it on, a shared module is fine. Most SvelteKit apps use SSR.
$count is a store subscription. $state is a rune. Mixing them, like $state inside $count, is a mess. Pick one per value.
load that calls cart.add or user.set writes into shared memory. Return { user } and pass data.user into context if the layout needs it.
Reassigning a context object, cart = new Cart(), breaks the link to children. Mutate fields, or call methods that mutate fields. cart.clear() already does that.
What to add next
The tutorial filter is $state that never needed a store. Server rows stay in load. Button clicks stay in $state.
A cart in memory vanishes on refresh. Persist line items with Drizzle and SQLite if you want them to survive.