Files
Job-Info/svelte-reference/03-STATE_MANAGEMENT.md
T
aewing 0b76a4b119 Cleanup: Remove old modes, add Svelte 5 reference library, update orchestrator
- 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
2026-01-21 03:56:01 +00:00

627 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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>
```