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
627 lines
13 KiB
Markdown
627 lines
13 KiB
Markdown
# 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.
|
||
|
||
```svelte
|
||
<!-- 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.
|
||
|
||
```svelte
|
||
<!-- 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.
|
||
|
||
```svelte
|
||
<!-- 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.
|
||
|
||
```typescript
|
||
// 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();
|
||
```
|
||
|
||
```svelte
|
||
<!-- 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.
|
||
|
||
```typescript
|
||
// 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();
|
||
```
|
||
|
||
```svelte
|
||
<!-- 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.
|
||
|
||
```svelte
|
||
<!-- 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()}
|
||
```
|
||
|
||
```svelte
|
||
<!-- 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.
|
||
|
||
```typescript
|
||
// 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.
|
||
|
||
```typescript
|
||
// 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 `$derived` for computed values
|
||
- Use `.svelte.ts` files 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 `$effect` without cleanup
|
||
- Destructure `$state` objects (breaks reactivity)
|
||
- Forget to handle loading/error states
|
||
|
||
---
|
||
|
||
## 🔍 Debugging State
|
||
|
||
```svelte
|
||
<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>
|
||
```
|