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
This commit is contained in:
@@ -0,0 +1,626 @@
|
||||
# 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>
|
||||
```
|
||||
Reference in New Issue
Block a user