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.
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
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.
<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.
// $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();<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.
// +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
};
}<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.
<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.
<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.
<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
- Use $state for local component state
- Use stores for shared state across components
- Leverage $derived for computed values
- Use $effect for side effects and synchronization
- Keep server and client state separate
- Implement optimistic updates for better UX
- Handle loading and error states properly
- Use URL state for bookmarkable filters
Next Steps
- Data Flow Guide - Learn about data flow patterns
- Component Patterns - Explore reusable component patterns
- API Reference - See available API endpoints