The Store Instinct
The first instinct when state needs to be shared: “I’ll put it in a store.”
This instinct is usually wrong.
Global state creates invisible dependencies. Any component can read from it. Any component can write to it. The more global state you have, the harder it becomes to understand what depends on what.
SvelteKit already gives you scoped state through routes and layouts. Most “shared” state fits naturally into this hierarchy. Global stores should be a last resort, not a first instinct.
The Problem with Global State
Every piece of global state is a question mark in your codebase:
// lib/stores/project.js
export const currentProject = writable(null) Looking at this file, you can’t answer:
- Who sets this value?
- When is it set?
- What happens if it’s null when something reads it?
- What components depend on it?
- When should it be cleared?
These questions have answers—they’re just scattered across your codebase. Every developer needs to hold this invisible dependency graph in their head.
Global stores also create subtle bugs:
<!-- ProjectHeader.svelte -->
<script>
import { currentProject } from '$lib/stores/project'
</script>
<h1>{$currentProject.name}</h1> What if the user navigates directly to a URL? The store might be null. What if they navigate between projects quickly? The store might show stale data. What if they open two tabs? The store doesn’t know which project is “current.”
Route-based state avoids all of this. The data loads fresh on each navigation. The URL is the source of truth.
When Route State Is Enough
Most “I need a store” situations are actually “I need to structure my routes better.”
“Multiple components need the same data”
Put it in a layout load function:
// routes/projects/[id]/+layout.server.ts
export async function load({ params }) {
const project = await db.getProject(params.id)
return { project }
} Every page under /projects/[id]/ now has access to data.project. No store needed.
“I need to share data between sibling components”
Lift the state to the common parent:
<!-- +page.svelte -->
<script>
let selectedTab = $state('overview')
</script>
<Tabs bind:selected={selectedTab} />
<TabContent tab={selectedTab} /> The parent owns the state. Children receive it as props. Simple, traceable, no store.
“I need the data on multiple unrelated pages”
Ask yourself: do you really? Or can each page load its own copy?
// routes/dashboard/+page.server.ts
export async function load({ locals }) {
return { user: await db.getUser(locals.userId) }
}
// routes/settings/+page.server.ts
export async function load({ locals }) {
return { user: await db.getUser(locals.userId) }
} Two loads, same data. The database call is fast. The data is always fresh. No synchronization bugs.
If you’re worried about duplicate fetches, remember: SvelteKit deduplicates fetch requests. And premature optimization creates more problems than it solves.
When Stores Are Justified
Some state genuinely needs to be global. Ask these questions:
- Is this needed on literally every page? (Not most pages—every page)
- Does this change independently of navigation?
- Would loading it per-route create a bad user experience?
If yes to all three, a store might be justified. Common examples:
Authentication state
The current user affects every page. It changes when they log in/out, not when they navigate.
// lib/auth.svelte.ts
import { browser } from '$app/environment'
class AuthState {
user = $state<User | null>(null)
loading = $state(true)
async initialize() {
if (!browser) return
try {
const response = await fetch('/api/me')
if (response.ok) {
this.user = await response.json()
}
} finally {
this.loading = false
}
}
async logout() {
await fetch('/api/logout', { method: 'POST' })
this.user = null
}
}
export const auth = new AuthState() Even this can often go in the root layout instead:
// routes/+layout.server.ts
export async function load({ locals }) {
return { user: locals.user }
} But if you need client-side logout without full navigation, a small auth store makes sense.
Theme/preferences
UI preferences persist across pages and shouldn’t reset on navigation:
// lib/theme.svelte.ts
import { browser } from '$app/environment'
class ThemeState {
mode = $state<'light' | 'dark'>('light')
constructor() {
if (browser) {
this.mode = (localStorage.getItem('theme') as 'light' | 'dark') || 'light'
}
}
toggle() {
this.mode = this.mode === 'light' ? 'dark' : 'light'
if (browser) {
localStorage.setItem('theme', this.mode)
}
}
}
export const theme = new ThemeState() Toast notifications
Toasts should persist across navigation:
// lib/toast.svelte.ts
class ToastState {
toasts = $state<Toast[]>([])
add(message: string, type: 'success' | 'error' = 'success') {
const id = Date.now()
this.toasts.push({ id, message, type })
setTimeout(() => this.remove(id), 5000)
}
remove(id: number) {
this.toasts = this.toasts.filter((t) => t.id !== id)
}
}
export const toasts = new ToastState() Notice the pattern: these are small, focused stores for truly cross-cutting concerns. Not large stores holding application data.
The Svelte 5 Way: Classes with $state
In Svelte 5, you can create stores using classes with $state:
// lib/counter.svelte.ts
class Counter {
count = $state(0)
increment() {
this.count++
}
decrement() {
this.count--
}
reset() {
this.count = 0
}
}
export const counter = new Counter() This is reactive. Components that read counter.count update when it changes:
<script>
import { counter } from '$lib/counter.svelte'
</script>
<p>Count: {counter.count}</p>
<button onclick={() => counter.increment()}>+</button> No writable/readable/derived. No $ prefix. Just classes with reactive properties.
The Litmus Test
Before creating a store, ask:
- Can this go in a layout load function? → Do that instead
- Can this be passed as props? → Do that instead
- Can each route load its own copy? → Do that instead
- Is this truly needed everywhere, changing independently of navigation? → OK, maybe a store
If you still want a store after these questions, keep it small. One concern per store. Don’t create a mega-store with all your application state.
Project Structure
A calm project has minimal global state:
lib/
├── components/
│ └── ...
├── server/
│ └── db.ts
├── auth.svelte.ts # Maybe
├── theme.svelte.ts # Maybe
└── toast.svelte.ts # Maybe
routes/
├── +layout.server.ts # User data for all pages
├── dashboard/
│ └── +page.server.ts # Dashboard data
├── projects/
│ ├── +layout.server.ts # Shared project data
│ └── [id]/
│ └── +page.server.ts # Single project data
└── settings/
└── +page.server.ts # Settings data Notice what’s missing: no lib/stores/ folder with dozens of store files. Data lives in route load functions. Global state is minimal.
This is what calm looks like. The data each page needs is obvious—look at its load function. Dependencies are explicit—look at the imports. No hidden coupling through global stores.
Add global state reluctantly. Remove it eagerly.
Next up: Errors happen. Load functions fail. Forms submit bad data. Networks drop. We’ll look at how to handle errors at the boundary—throwing them in one place, catching them in another.