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

13 KiB
Raw Blame History

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 $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

<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>