0b76a4b119
- Delete Mode1Test, Mode2Test, Mode3Test, Mode5Test (keep only Mode6Test) - Delete old documentation, Docker files, and k8s configs - Add comprehensive Svelte 5 reference library (svelte-reference/) - Update ModeSwitch orchestrator: - Fix port to 3005 (never use 3000/3001) - Add API endpoints for mode switching - Add bun-types and proper tsconfig - Clean up mode folder mappings
13 KiB
13 KiB
Svelte 5 State Management Guide
Overview
Svelte 5 provides multiple patterns for state management, from local component state to global application state. Choose the right pattern based on your needs.
📊 State Management Decision Tree
Is the state used by only one component?
├── YES → Use local $state()
└── NO → Is it shared between parent/child?
├── YES → Pass as props or use $bindable()
└── NO → Is it shared across unrelated components?
├── YES → Use a .svelte.ts store
└── NO → Is it server data that needs caching?
└── YES → Use SvelteKit load functions
🏠 Pattern 1: Local Component State
For state that belongs to a single component.
<!-- Counter.svelte -->
<script lang="ts">
let count = $state(0);
let history = $state<number[]>([]);
let average = $derived(
history.length > 0
? history.reduce((a, b) => a + b, 0) / history.length
: 0
);
function increment() {
count++;
history.push(count);
}
function reset() {
count = 0;
history = [];
}
</script>
<div>
<p>Count: {count}</p>
<p>Average: {average.toFixed(2)}</p>
<button onclick={increment}>+1</button>
<button onclick={reset}>Reset</button>
</div>
📤 Pattern 2: Props Down, Events Up
Classic unidirectional data flow.
<!-- Parent.svelte -->
<script lang="ts">
import Child from './Child.svelte';
let items = $state(['Apple', 'Banana', 'Cherry']);
function addItem(item: string) {
items.push(item);
}
function removeItem(index: number) {
items.splice(index, 1);
}
</script>
<Child
{items}
onadd={addItem}
onremove={removeItem}
/>
<!-- Child.svelte -->
<script lang="ts">
interface Props {
items: string[];
onadd: (item: string) => void;
onremove: (index: number) => void;
}
let { items, onadd, onremove }: Props = $props();
let newItem = $state('');
function handleAdd() {
if (newItem.trim()) {
onadd(newItem);
newItem = '';
}
}
</script>
<div>
<input bind:value={newItem} placeholder="Add item" />
<button onclick={handleAdd}>Add</button>
<ul>
{#each items as item, i (i)}
<li>
{item}
<button onclick={() => onremove(i)}>×</button>
</li>
{/each}
</ul>
</div>
🔗 Pattern 3: Two-Way Binding with $bindable
For controlled form components.
<!-- SearchInput.svelte -->
<script lang="ts">
interface Props {
value?: string;
placeholder?: string;
onsearch?: (query: string) => void;
}
let {
value = $bindable(''),
placeholder = 'Search...',
onsearch
}: Props = $props();
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
onsearch?.(value);
}
}
</script>
<input
type="search"
bind:value
{placeholder}
onkeydown={handleKeydown}
/>
<!-- Usage -->
<!--
<script lang="ts">
let searchQuery = $state('');
function handleSearch(query: string) {
console.log('Searching:', query);
}
</script>
<SearchInput bind:value={searchQuery} onsearch={handleSearch} />
<p>Current query: {searchQuery}</p>
-->
🏪 Pattern 4: Global Stores (.svelte.ts files)
For state shared across unrelated components.
// stores/auth.svelte.ts
export interface User {
id: string;
name: string;
email: string;
avatar?: string;
}
class AuthStore {
user = $state<User | null>(null);
loading = $state(false);
error = $state<string | null>(null);
isAuthenticated = $derived(this.user !== null);
async login(email: string, password: string) {
this.loading = true;
this.error = null;
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!response.ok) {
throw new Error('Login failed');
}
this.user = await response.json();
} catch (e) {
this.error = e instanceof Error ? e.message : 'Unknown error';
throw e;
} finally {
this.loading = false;
}
}
async logout() {
await fetch('/api/auth/logout', { method: 'POST' });
this.user = null;
}
async checkSession() {
try {
const response = await fetch('/api/auth/me');
if (response.ok) {
this.user = await response.json();
}
} catch {
this.user = null;
}
}
}
export const auth = new AuthStore();
<!-- NavBar.svelte -->
<script lang="ts">
import { auth } from '$lib/stores/auth.svelte';
</script>
<nav>
{#if auth.isAuthenticated}
<span>Welcome, {auth.user?.name}</span>
<button onclick={() => auth.logout()}>Logout</button>
{:else}
<a href="/login">Login</a>
{/if}
</nav>
🛒 Pattern 5: Complex Store with Actions
For stores with complex business logic.
// stores/cart.svelte.ts
export interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
class CartStore {
items = $state<CartItem[]>([]);
// Derived values
itemCount = $derived(
this.items.reduce((sum, item) => sum + item.quantity, 0)
);
subtotal = $derived(
this.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
tax = $derived(this.subtotal * 0.1);
total = $derived(this.subtotal + this.tax);
isEmpty = $derived(this.items.length === 0);
// Actions
addItem(product: { id: string; name: string; price: number }) {
const existing = this.items.find(item => item.id === product.id);
if (existing) {
existing.quantity++;
} else {
this.items.push({
...product,
quantity: 1
});
}
this.persist();
}
removeItem(id: string) {
const index = this.items.findIndex(item => item.id === id);
if (index !== -1) {
this.items.splice(index, 1);
this.persist();
}
}
updateQuantity(id: string, quantity: number) {
const item = this.items.find(item => item.id === id);
if (item) {
if (quantity <= 0) {
this.removeItem(id);
} else {
item.quantity = quantity;
this.persist();
}
}
}
clear() {
this.items = [];
this.persist();
}
// Persistence
private persist() {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('cart', JSON.stringify($state.snapshot(this.items)));
}
}
hydrate() {
if (typeof localStorage !== 'undefined') {
const saved = localStorage.getItem('cart');
if (saved) {
try {
this.items = JSON.parse(saved);
} catch {
this.items = [];
}
}
}
}
}
export const cart = new CartStore();
<!-- CartButton.svelte -->
<script lang="ts">
import { cart } from '$lib/stores/cart.svelte';
</script>
<button class="cart-button">
🛒
{#if cart.itemCount > 0}
<span class="badge">{cart.itemCount}</span>
{/if}
</button>
<!-- CartSummary.svelte -->
<script lang="ts">
import { cart } from '$lib/stores/cart.svelte';
</script>
<div class="cart-summary">
<p>Subtotal: ${cart.subtotal.toFixed(2)}</p>
<p>Tax: ${cart.tax.toFixed(2)}</p>
<p><strong>Total: ${cart.total.toFixed(2)}</strong></p>
</div>
🌐 Pattern 6: Context for Component Trees
Pass state down a component tree without prop drilling.
<!-- ThemeProvider.svelte -->
<script lang="ts" module>
import { setContext, getContext } from 'svelte';
const THEME_KEY = Symbol('theme');
export type Theme = 'light' | 'dark' | 'system';
export interface ThemeContext {
theme: { value: Theme };
resolvedTheme: { value: 'light' | 'dark' };
setTheme: (theme: Theme) => void;
}
export function getTheme(): ThemeContext {
return getContext(THEME_KEY);
}
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
import { browser } from '$app/environment';
interface Props {
defaultTheme?: Theme;
children: Snippet;
}
let { defaultTheme = 'system', children }: Props = $props();
let theme = $state<Theme>(defaultTheme);
let systemTheme = $state<'light' | 'dark'>('light');
let resolvedTheme = $derived<'light' | 'dark'>(
theme === 'system' ? systemTheme : theme
);
// Watch system preference
$effect(() => {
if (!browser) return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
systemTheme = mediaQuery.matches ? 'dark' : 'light';
const handler = (e: MediaQueryListEvent) => {
systemTheme = e.matches ? 'dark' : 'light';
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
});
// Apply theme to document
$effect(() => {
if (!browser) return;
document.documentElement.dataset.theme = resolvedTheme;
});
setContext<ThemeContext>(THEME_KEY, {
get theme() { return { value: theme }; },
get resolvedTheme() { return { value: resolvedTheme }; },
setTheme: (t: Theme) => { theme = t; }
});
</script>
{@render children()}
<!-- ThemeToggle.svelte -->
<script lang="ts">
import { getTheme } from './ThemeProvider.svelte';
const { theme, setTheme } = getTheme();
</script>
<button onclick={() => setTheme(theme.value === 'dark' ? 'light' : 'dark')}>
{theme.value === 'dark' ? '☀️' : '🌙'}
</button>
📡 Pattern 7: Async State with Loading/Error
Handle async operations elegantly.
// stores/async.svelte.ts
export type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
export function createAsyncState<T>() {
let state = $state<AsyncState<T>>({ status: 'idle' });
return {
get state() { return state; },
get isLoading() { return state.status === 'loading'; },
get isSuccess() { return state.status === 'success'; },
get isError() { return state.status === 'error'; },
get data() { return state.status === 'success' ? state.data : null; },
get error() { return state.status === 'error' ? state.error : null; },
async run(fn: () => Promise<T>) {
state = { status: 'loading' };
try {
const data = await fn();
state = { status: 'success', data };
return data;
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e));
state = { status: 'error', error };
throw error;
}
},
reset() {
state = { status: 'idle' };
}
};
}
// Usage in a store
class UserStore {
private usersAsync = createAsyncState<User[]>();
get users() { return this.usersAsync.data ?? []; }
get loading() { return this.usersAsync.isLoading; }
get error() { return this.usersAsync.error; }
async fetchUsers() {
await this.usersAsync.run(async () => {
const res = await fetch('/api/users');
return res.json();
});
}
}
🔄 Pattern 8: Undo/Redo State
Maintain history for undo/redo functionality.
// stores/history.svelte.ts
export function createHistoryState<T>(initial: T) {
let past = $state<T[]>([]);
let present = $state<T>(initial);
let future = $state<T[]>([]);
return {
get value() { return present; },
get canUndo() { return past.length > 0; },
get canRedo() { return future.length > 0; },
set(value: T) {
past.push($state.snapshot(present) as T);
present = value;
future = [];
},
undo() {
if (past.length === 0) return;
future.unshift($state.snapshot(present) as T);
present = past.pop()!;
},
redo() {
if (future.length === 0) return;
past.push($state.snapshot(present) as T);
present = future.shift()!;
},
reset(value: T) {
past = [];
present = value;
future = [];
}
};
}
// Usage
const editor = createHistoryState({ text: '', fontSize: 16 });
// Make changes
editor.set({ text: 'Hello', fontSize: 16 });
editor.set({ text: 'Hello World', fontSize: 16 });
// Undo
editor.undo(); // Back to 'Hello'
editor.undo(); // Back to ''
// Redo
editor.redo(); // Forward to 'Hello'
📋 State Management Best Practices
✅ Do:
- Keep state as local as possible
- Use
$derivedfor computed values - Use
.svelte.tsfiles for shared state - Type your state with TypeScript
- Use
$state.snapshot()before serializing - Clean up effects that create subscriptions
❌ Don't:
- Create global stores for component-local state
- Mutate derived values
- Put async logic in
$effectwithout cleanup - Destructure
$stateobjects (breaks reactivity) - Forget to handle loading/error states
🔍 Debugging State
<script lang="ts">
let user = $state({ name: 'John', age: 30 });
let items = $state([1, 2, 3]);
// Log all changes during development
$inspect(user, items);
// Or with custom handler
$inspect(user).with((type, ...values) => {
if (type === 'update') {
console.table(values);
}
});
</script>