State Management

Guide to state management patterns in BrewHoard using Svelte 5 runes

BrewHoard leverages Svelte 5’s powerful rune system for reactive state management. This guide covers the different types of state and how to manage them effectively in a SvelteKit application.

Loading diagram...

Svelte 5 Runes Overview

Svelte 5 introduces runes as the new way to declare reactive state and behavior. Runes are functions that start with $ and provide fine-grained reactivity.

Core Runes

  • $state() - Declares reactive state
  • $derived() - Creates computed values
  • $effect() - Runs side effects when dependencies change
Loading diagram...

Component State vs Shared State

Component State with $state

Component state is local to a component and automatically cleans up when the component is destroyed.

Svelte
<script>
  // Simple reactive state
  let count = $state(0);

  // Object state
  let beer = $state({
    name: '',
    brewery: '',
    rating: 0
  });

  // Array state
  let selectedBeers = $state([]);

  function addBeer(newBeer) {
    selectedBeers.push(newBeer);
  }

  function updateRating(newRating) {
    beer.rating = newRating;
  }

  // Reactive expressions work automatically
  $effect(() => {
    console.log('Count changed:', count);
  });
</script>

<button on:click={() => count++}>
  Count: {count}
</button>

Shared State with Stores

For state that needs to be shared across components, use Svelte’s store system with runes.

JavaScript
// $lib/stores/collection.js
import { writable } from 'svelte/store';

// Traditional store
export const collectionStore = writable([]);

// Store with runes (Svelte 5 style)
class CollectionStore {
  beers = $state([]);
  isLoading = $state(false);
  error = $state(null);

  // Computed values
  totalBeers = $derived(this.beers.length);
  averageRating = $derived(() => {
    if (this.beers.length === 0) return 0;
    const sum = this.beers.reduce((acc, beer) => acc + (beer.rating || 0), 0);
    return sum / this.beers.length;
  });

  // Actions
  async loadBeers(userId) {
    this.isLoading = true;
    this.error = null;

    try {
      const response = await fetch(`/api/collection/${userId}`);
      this.beers = await response.json();
    } catch (err) {
      this.error = err.message;
    } finally {
      this.isLoading = false;
    }
  }

  addBeer(beer) {
    this.beers.push(beer);
  }

  removeBeer(beerId) {
    this.beers = this.beers.filter(beer => beer.id !== beerId);
  }
}

export const collection = new CollectionStore();
Svelte
<script>
  import { collection } from '$lib/stores/collection.js';

  // Reactive to store changes
  $effect(() => {
    console.log('Collection updated:', collection.totalBeers);
  });
</script>

<div>
  <h2>My Collection ({collection.totalBeers} beers)</h2>
  <p>Average Rating: {collection.averageRating.toFixed(1)} ⭐</p>

  {#if collection.isLoading}
    <p>Loading...</p>
  {:else if collection.error}
    <p>Error: {collection.error}</p>
  {:else}
    <ul>
      {#each collection.beers as beer}
        <li>{beer.name} - {beer.rating}⭐</li>
      {/each}
    </ul>
  {/if}
</div>

Server State (Load Functions)

Server state is managed through SvelteKit’s load functions and is automatically passed to components as props.

JavaScript
// +page.server.js
import { getUserCollection } from '$lib/collection/collection.js';

/** @type {import('./$types').PageServerLoad} */
export async function load({ locals, url }) {
  const userId = locals.user.id;
  const searchParams = url.searchParams;

  // Server state with filtering
  const filters = {
    style: searchParams.get('style'),
    minRating: parseFloat(searchParams.get('minRating')) || 0,
    sortBy: searchParams.get('sortBy') || 'name'
  };

  const beers = await getUserCollection(userId, filters);

  return {
    beers,
    filters,
    user: locals.user
  };
}
Svelte
<script>
  // Server data passed as props
  let { data } = $props();

  // Reactive to server data changes
  $effect(() => {
    console.log('Server data loaded:', data.beers.length, 'beers');
  });
</script>

<div>
  <h1>My Beer Collection</h1>

  <!-- Filters from server state -->
  <div class="filters">
    <p>Style: {data.filters.style || 'All'}</p>
    <p>Min Rating: {data.filters.minRating}</p>
  </div>

  <ul>
    {#each data.beers as beer}
      <li>
        <strong>{beer.name}</strong> by {beer.brewery}
        <span>Rating: {beer.rating}⭐</span>
      </li>
    {/each}
  </ul>
</div>

URL State (searchParams)

URL state allows bookmarkable and shareable application states using the browser’s URL.

Svelte
<script>
  import { page } from '$app/stores';
  import { goto } from '$app/navigation';

  // Reactive URL state
  let searchQuery = $state('');
  let selectedStyle = $state('all');
  let sortOrder = $state('name');

  // Sync with URL on mount and URL changes
  $effect(() => {
    const params = $page.url.searchParams;
    searchQuery = params.get('q') || '';
    selectedStyle = params.get('style') || 'all';
    sortOrder = params.get('sort') || 'name';
  });

  // Update URL when state changes
  $effect(() => {
    const params = new URLSearchParams();
    if (searchQuery) params.set('q', searchQuery);
    if (selectedStyle !== 'all') params.set('style', selectedStyle);
    if (sortOrder !== 'name') params.set('sort', sortOrder);

    const newUrl = `?${params.toString()}`;
    goto(newUrl, { replaceState: true, keepFocus: true });
  });

  function updateSearch(value) {
    searchQuery = value;
  }

  function updateStyle(style) {
    selectedStyle = style;
  }
</script>

<div class="search-controls">
  <input
    type="text"
    placeholder="Search beers..."
    value={searchQuery}
    on:input={(e) => updateSearch(e.target.value)}
  />

  <select value={selectedStyle} on:change={(e) => updateStyle(e.target.value)}>
    <option value="all">All Styles</option>
    <option value="IPA">IPA</option>
    <option value="Stout">Stout</option>
    <option value="Lager">Lager</option>
  </select>

  <select value={sortOrder} on:change={(e) => updateSort(e.target.value)}>
    <option value="name">Name</option>
    <option value="rating">Rating</option>
    <option value="brewery">Brewery</option>
  </select>
</div>

Form State

Form state management combines local component state with server validation and optimistic updates.

Svelte
<script>
  import { enhance } from '$app/forms';
  import { invalidateAll } from '$app/navigation';

  // Form state
  let formData = $state({
    name: '',
    brewery: '',
    style: '',
    abv: 0,
    rating: 0
  });

  let isSubmitting = $state(false);
  let validationErrors = $state({});

  // Form validation
  let isFormValid = $derived(() => {
    return formData.name.trim() && formData.brewery.trim() && formData.abv > 0;
  });

  // Reset form
  function resetForm() {
    formData = {
      name: '',
      brewery: '',
      style: '',
      abv: 0,
      rating: 0
    };
    validationErrors = {};
  }

  // Optimistic update
  function optimisticAdd() {
    // Could update local collection store immediately
    // collection.addBeer(formData);
  }
</script>

<form method="POST" action="?/addBeer" use:enhance={() => {
  return async ({ update, result }) => {
    isSubmitting = true;

    await update();

    if (result.type === 'success') {
      resetForm();
      await invalidateAll(); // Refresh server data
    } else if (result.type === 'failure') {
      validationErrors = result.data?.errors || {};
    }

    isSubmitting = false;
  };
}}>
  <div>
    <label for="name">Beer Name *</label>
    <input
      id="name"
      name="name"
      type="text"
      bind:value={formData.name}
      required
    />
    {#if validationErrors.name}
      <span class="error">{validationErrors.name}</span>
    {/if}
  </div>

  <div>
    <label for="brewery">Brewery *</label>
    <input
      id="brewery"
      name="brewery"
      type="text"
      bind:value={formData.brewery}
      required
    />
  </div>

  <div>
    <label for="abv">ABV *</label>
    <input
      id="abv"
      name="abv"
      type="number"
      step="0.1"
      bind:value={formData.abv}
      min="0"
      max="20"
      required
    />
  </div>

  <div>
    <label for="rating">Initial Rating</label>
    <input
      id="rating"
      name="rating"
      type="number"
      bind:value={formData.rating}
      min="0"
      max="5"
      step="0.5"
    />
  </div>

  <button type="submit" disabled={!isFormValid || isSubmitting}>
    {#if isSubmitting}
      Adding...
    {:else}
      Add Beer
    {/if}
  </button>
</form>

Optimistic Updates

Optimistic updates provide immediate UI feedback while server operations complete in the background.

Svelte
<script>
  import { collection } from '$lib/stores/collection.js';
  import { enhance } from '$app/forms';

  let pendingUpdates = $state(new Map());

  function optimisticRate(beerId, newRating) {
    // Store original rating for rollback
    const originalBeer = collection.beers.find(b => b.id === beerId);
    if (!originalBeer) return;

    const originalRating = originalBeer.rating;
    pendingUpdates.set(beerId, originalRating);

    // Update UI immediately
    originalBeer.rating = newRating;

    // Trigger server update
    updateRating(beerId, newRating);
  }

  async function updateRating(beerId, rating) {
    try {
      const response = await fetch(`/api/beers/${beerId}/rate`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ rating })
      });

      if (!response.ok) throw new Error('Failed to update rating');

      // Success - remove from pending
      pendingUpdates.delete(beerId);
    } catch (error) {
      // Failure - rollback optimistic update
      const originalRating = pendingUpdates.get(beerId);
      if (originalRating !== undefined) {
        const beer = collection.beers.find(b => b.id === beerId);
        if (beer) beer.rating = originalRating;
      }
      pendingUpdates.delete(beerId);
      console.error('Failed to update rating:', error);
    }
  }
</script>

<ul>
  {#each collection.beers as beer}
    <li>
      {beer.name}
      <span class={pendingUpdates.has(beer.id) ? 'pending' : ''}>
        Rating: {beer.rating}⭐
      </span>
      <button on:click={() => optimisticRate(beer.id, beer.rating + 0.5)}>
        +0.5
      </button>
    </li>
  {/each}
</ul>

<style>
  .pending {
    opacity: 0.7;
    font-style: italic;
  }
</style>

Best Practices

  1. Use $state for local component state
  2. Use stores for shared state across components
  3. Leverage $derived for computed values
  4. Use $effect for side effects and synchronization
  5. Keep server and client state separate
  6. Implement optimistic updates for better UX
  7. Handle loading and error states properly
  8. Use URL state for bookmarkable filters

Next Steps