Svelte 5 Runes: Complete Guide to Reactive State Management
Master Svelte 5's Runes system for reactive state management. Complete tutorial covering $state, $derived, $effect, $props, and migration strategies from Svelte 4 with practical examples.
Svelte 5 represents the most significant evolution in Svelte's history, introducing Runes—a new paradigm for reactive state management that fundamentally changes how you build Svelte applications. Unlike Svelte 4's implicit reactivity through let declarations and $: labeled statements, Runes provide explicit, powerful primitives that make reactivity more predictable, performant, and easier to reason about.
This guide walks through Svelte 5's Runes system from first principles, covering practical patterns for real-world applications, migration strategies from Svelte 4, and advanced techniques for building high-performance user interfaces.
Understanding the Shift to Runes
Svelte 4's reactivity model relied on compile-time analysis of variable assignments. When you wrote let count = 0 and updated it with count++, the Svelte compiler generated code to automatically update the DOM. While elegant for simple cases, this approach created challenges:
Implicit Reactivity Problems:
- Hard to predict what would trigger updates
- Difficult to share reactive state between components
- No clear distinction between reactive and non-reactive values
- Performance overhead from compiler-generated subscriptions
- Confusion when destructuring or using array methods
Svelte 5's Runes solve these issues through explicit reactivity markers. Instead of relying on compiler magic, you explicitly declare reactive state using special function-like symbols called Runes.
<!-- Svelte 4 approach -->
<script>
let count = 0; // Implicitly reactive
$: doubled = count * 2; // Reactive statement
$: { // Reactive block
console.log('Count changed:', count);
}
</script>
<!-- Svelte 5 approach with Runes -->
<script>
let count = $state(0); // Explicitly reactive
let doubled = $derived(count * 2); // Derived state
$effect(() => { // Effect for side effects
console.log('Count changed:', count);
});
</script>
The Runes approach makes reactivity explicit and predictable. When you see $state(), you know exactly what's reactive. When you see $derived(), you understand this value depends on other reactive values. This explicitness scales better to large applications and makes code easier to maintain.
Core Runes Overview
Svelte 5 provides six primary Runes, each serving a specific purpose in reactive state management:
$state(): Creates reactive state$derived(): Computes values based on other reactive state$effect(): Runs side effects when dependencies change$props(): Declares component props$bindable(): Creates two-way bindable props$inspect(): Debugging tool for reactive values
Let's explore each Rune in detail with practical examples.
$state(): Reactive State
The $state() Rune creates reactive state that automatically updates the UI when modified. This is the foundation of Svelte 5's reactivity system.
Basic Usage
<script>
let count = $state(0);
let name = $state('');
let items = $state([]);
</script>
<button onclick={() => count++}>
Clicked {count} {count === 1 ? 'time' : 'times'}
</button>
<input bind:value={name} placeholder="Enter name" />
<p>Hello, {name || 'stranger'}!</p>
<button onclick={() => items.push(Item ${items.length + 1})}>
Add Item
</button>
<ul>
{#each items as item}
<li>{item}</li>
{/each}
</ul>
State created with $state() is deeply reactive. When you mutate nested properties or array elements, Svelte automatically detects the changes:
<script>
let user = $state({
name: 'Alice',
preferences: {
theme: 'dark',
notifications: true
}
});
function toggleTheme() {
// Deeply reactive - this triggers updates
user.preferences.theme = user.preferences.theme === 'dark' ? 'light' : 'dark';
}
</script>
<div class={user.preferences.theme}>
<button onclick={toggleTheme}>
Current theme: {user.preferences.theme}
</button>
</div>
State Class Fields
In component classes or exported modules, use $state() as a class field decorator:
<script>
class TodoList {
items = $state([]);
filter = $state('all');
addItem(text) {
this.items.push({
id: crypto.randomUUID(),
text,
completed: false
});
}
get filteredItems() {
if (this.filter === 'active') {
return this.items.filter(item => !item.completed);
}
if (this.filter === 'completed') {
return this.items.filter(item => item.completed);
}
return this.items;
}
}
let todos = new TodoList();
</script>
<input
onkeydown={(e) => {
if (e.key === 'Enter' && e.target.value) {
todos.addItem(e.target.value);
e.target.value = '';
}
}}
placeholder="Add todo..."
/>
<div>
<button onclick={() => todos.filter = 'all'}>All</button>
<button onclick={() => todos.filter = 'active'}>Active</button>
<button onclick={() => todos.filter = 'completed'}>Completed</button>
</div>
<ul>
{#each todos.filteredItems as item (item.id)}
<li>
<input
type="checkbox"
bind:checked={item.completed}
/>
{item.text}
</li>
{/each}
</ul>
$state.raw(): Non-Deep Reactivity
For large objects where you only need surface-level reactivity, use $state.raw():
<script>
// Only the reference change triggers reactivity, not nested mutations
let largeDataset = $state.raw({
records: new Array(10000).fill(0).map((_, i) => ({
id: i,
value: Math.random()
}))
});
function replaceData() {
// This triggers reactivity because we're replacing the whole object
largeDataset = {
records: new Array(10000).fill(0).map((_, i) => ({
id: i,
value: Math.random()
}))
};
}
function mutateFirstRecord() {
// This WON'T trigger reactivity with $state.raw()
largeDataset.records[0].value = 999;
}
</script>
Use $state.raw() for performance when working with large immutable data structures or when you control updates through reference replacement rather than mutations.
$derived(): Computed State
The $derived() Rune creates computed values that automatically recalculate when their dependencies change. This replaces Svelte 4's $: reactive statements for derived values.
Basic Derived State
<script>
let firstName = $state('John');
let lastName = $state('Doe');
// Automatically recalculates when firstName or lastName changes
let fullName = $derived(${firstName} ${lastName});
let items = $state([
{ name: 'Apple', price: 1.20 },
{ name: 'Banana', price: 0.50 },
{ name: 'Orange', price: 0.80 }
]);
// Derived from array state
let total = $derived(
items.reduce((sum, item) => sum + item.price, 0)
);
let formattedTotal = $derived(
$${total.toFixed(2)}
);
</script>
<input bind:value={firstName} />
<input bind:value={lastName} />
<p>Full name: {fullName}</p>
<ul>
{#each items as item}
<li>{item.name}: ${item.price.toFixed(2)}</li>
{/each}
</ul>
<p><strong>Total: {formattedTotal}</strong></p>
$derived.by(): Complex Derivations
For complex computations that require multiple statements, use $derived.by():
<script>
let products = $state([
{ id: 1, name: 'Laptop', price: 999, category: 'electronics', inStock: true },
{ id: 2, name: 'Mouse', price: 25, category: 'electronics', inStock: true },
{ id: 3, name: 'Desk', price: 299, category: 'furniture', inStock: false },
{ id: 4, name: 'Chair', price: 199, category: 'furniture', inStock: true }
]);
let selectedCategory = $state('all');
let minPrice = $state(0);
let maxPrice = $state(1000);
let showOutOfStock = $state(false);
let filteredProducts = $derived.by(() => {
let result = products;
// Filter by category
if (selectedCategory !== 'all') {
result = result.filter(p => p.category === selectedCategory);
}
// Filter by price range
result = result.filter(p => p.price >= minPrice && p.price <= maxPrice);
// Filter by stock
if (!showOutOfStock) {
result = result.filter(p => p.inStock);
}
// Sort by price
return result.sort((a, b) => a.price - b.price);
});
let stats = $derived.by(() => {
return {
total: filteredProducts.length,
averagePrice: filteredProducts.length > 0
? filteredProducts.reduce((sum, p) => sum + p.price, 0) / filteredProducts.length
: 0,
categories: new Set(filteredProducts.map(p => p.category)).size
};
});
</script>
<div class="filters">
<select bind:value={selectedCategory}>
<option value="all">All Categories</option>
<option value="electronics">Electronics</option>
<option value="furniture">Furniture</option>
</select>
<label>
Min Price:
<input type="number" bind:value={minPrice} />
</label>
<label>
Max Price:
<input type="number" bind:value={maxPrice} />
</label>
<label>
<input type="checkbox" bind:checked={showOutOfStock} />
Show out of stock
</label>
</div>
<div class="stats">
<p>Found {stats.total} products</p>
<p>Average price: ${stats.averagePrice.toFixed(2)}</p>
<p>Categories: {stats.categories}</p>
</div>
<ul>
{#each filteredProducts as product (product.id)}
<li>
{product.name} - ${product.price}
{#if !product.inStock}
<span class="out-of-stock">Out of stock</span>
{/if}
</li>
{/each}
</ul>
Derived state automatically memoizes—it only recalculates when dependencies change. This makes complex filtering and computations efficient even with large datasets.
$effect(): Side Effects
The $effect() Rune runs side effects in response to state changes. This replaces Svelte 4's reactive blocks ($: { }).
Basic Effects
<script>
let count = $state(0);
let name = $state('');
// Effect runs when count changes
$effect(() => {
console.log('Count is now:', count);
if (count >= 10) {
alert('Count reached 10!');
}
});
// Effect runs when name changes
$effect(() => {
if (name) {
document.title = Hello, ${name}!;
}
});
// Cleanup function
$effect(() => {
const interval = setInterval(() => {
console.log('Current count:', count);
}, 1000);
// Return cleanup function
return () => {
clearInterval(interval);
};
});
</script>
<button onclick={() => count++}>
Count: {count}
</button>
<input bind:value={name} placeholder="Enter your name" />
Effect Timing: $effect.pre() and $effect.root()
Svelte provides variants of $effect() for different timing requirements:
<script>
let value = $state(0);
let element;
// Runs BEFORE the DOM updates
$effect.pre(() => {
console.log('Pre-update, value:', value);
if (element) {
console.log('Current DOM text:', element.textContent);
}
});
// Runs AFTER the DOM updates (default)
$effect(() => {
console.log('Post-update, value:', value);
if (element) {
console.log('Updated DOM text:', element.textContent);
}
});
</script>
<div bind:this={element}>
Value: {value}
</div>
<button onclick={() => value++}>Increment</button>
Use $effect.root() for effects that should persist beyond component lifecycle:
<script>
import { $effect } from 'svelte';
// Create a persistent effect
const cleanup = $effect.root(() => {
// This effect won't be automatically cleaned up
const interval = setInterval(() => {
console.log('Persistent effect running');
}, 1000);
return () => {
clearInterval(interval);
};
});
// Manually clean up when needed
// cleanup();
</script>
LocalStorage Sync Example
<script>
let preferences = $state({
theme: 'light',
fontSize: 16,
sidebarOpen: true
});
// Load from localStorage on mount
$effect(() => {
const saved = localStorage.getItem('preferences');
if (saved) {
try catch (e) {
console.error('Failed to parse saved preferences');
}
}
});
// Sync to localStorage when preferences change
$effect(() => {
localStorage.setItem('preferences', JSON.stringify(preferences));
});
</script>
<div class:dark={preferences.theme === 'dark'} style="font-size: {preferences.fontSize}px">
<button onclick={() => preferences.theme = preferences.theme === 'light' ? 'dark' : 'light'}>
Toggle Theme
</button>
<button onclick={() => preferences.fontSize = Math.min(preferences.fontSize + 2, 24)}>
Increase Font
</button>
<button onclick={() => preferences.fontSize = Math.max(preferences.fontSize - 2, 12)}>
Decrease Font
</button>
<button onclick={() => preferences.sidebarOpen = !preferences.sidebarOpen}>
{preferences.sidebarOpen ? 'Close' : 'Open'} Sidebar
</button>
</div>
$props(): Component Props
The $props() Rune declares component props with improved TypeScript support and better ergonomics than Svelte 4's export let syntax.
Basic Props
<!-- UserCard.svelte -->
<script>
let { name, email, avatar, role = 'user' } = $props();
</script>
<div class="user-card">
<img src={avatar} alt={name} />
<h3>{name}</h3>
<p>{email}</p>
<span class="role">{role}</span>
</div>
<!-- Usage -->
<script>
import UserCard from './UserCard.svelte';
</script>
<UserCard
name="Alice Johnson"
email="alice@example.com"
avatar="/avatars/alice.jpg"
role="admin"
/>
TypeScript Props
<!-- Button.svelte -->
<script lang="ts">
interface Props {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
onclick?: () => void;
children?: import('svelte').Snippet;
}
let {
variant = 'primary',
size = 'md',
disabled = false,
onclick,
children
}: Props = $props();
</script>
<button
class="btn btn-{variant} btn-{size}"
{disabled}
{onclick}
>
{@render children?.()}
</button>
<style>
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-sm { font-size: 0.875rem; }
.btn-md { font-size: 1rem; }
.btn-lg { font-size: 1.125rem; }
.btn-primary { background: #3b82f6; color: white; }
.btn-secondary { background: #6b7280; color: white; }
.btn-danger { background: #ef4444; color: white; }
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
Rest Props
Capture remaining props with rest syntax:
<script>
let { class: className, ...rest } = $props();
</script>
<div class="wrapper {className}" {...rest}>
<!-- Content -->
</div>
$bindable(): Two-Way Binding
The $bindable() Rune creates props that can be bound bidirectionally, enabling parent components to sync state with child components.
<!-- Counter.svelte -->
<script>
let { count = $bindable(0) } = $props();
</script>
<button onclick={() => count++}>
Count: {count}
</button>
<!-- Parent.svelte -->
<script>
import Counter from './Counter.svelte';
let parentCount = $state(0);
</script>
<p>Parent count: {parentCount}</p>
<!-- Two-way binding -->
<Counter bind:count={parentCount} />
<button onclick={() => parentCount = 0}>
Reset from parent
</button>
Complex Bindable Example: Form Components
<!-- FormInput.svelte -->
<script>
let {
value = $bindable(''),
label,
type = 'text',
error = $bindable(''),
required = false
} = $props();
function validate() {
if (required && !value) {
error = ${label} is required;
return false;
}
error = '';
return true;
}
</script>
<div class="form-group">
<label>
{label}
{#if required}<span class="required">*</span>{/if}
</label>
<input
{type}
bind:value
onblur={validate}
class:error={error}
/>
{#if error}
<span class="error-message">{error}</span>
{/if}
</div>
<!-- Parent Form -->
<script>
import FormInput from './FormInput.svelte';
let email = $state('');
let password = $state('');
let emailError = $state('');
let passwordError = $state('');
function handleSubmit() {
console.log('Submit:', { email, password });
}
</script>
<form onsubmit|preventDefault={handleSubmit}>
<FormInput
bind:value={email}
bind:error={emailError}
label="Email"
type="email"
required
/>
<FormInput
bind:value={password}
bind:error={passwordError}
label="Password"
type="password"
required
/>
<button type="submit">
Sign In
</button>
</form>
$inspect(): Debugging Reactive State
The $inspect() Rune logs reactive values whenever they change, invaluable for debugging:
<script>
let count = $state(0);
let user = $state({ name: 'Alice', age: 30 });
// Logs whenever count changes
$inspect(count);
// Logs whenever user changes
$inspect(user);
// Custom label
$inspect('User object:', user);
// Multiple values
$inspect({ count, userName: user.name });
</script>
<button onclick={() => count++}>
Count: {count}
</button>
<button onclick={() => user.age++}>
Age: {user.age}
</button>
In development, $inspect() logs to the console. In production builds, it's automatically removed.
Migrating from Svelte 4 to Svelte 5
Svelte 5 maintains backward compatibility, so you can migrate gradually. Here's a systematic approach:
Step 1: Update Dependencies
npm install svelte@5
Step 2: Run the Migration Tool
Svelte provides an automated migration tool:
npx sv migrate
This tool converts most common patterns automatically:
<!-- Before (Svelte 4) -->
<script>
export let count = 0;
$: doubled = count * 2;
$: {
console.log('Count changed');
}
</script>
<!-- After (Svelte 5 with Runes) -->
<script>
let = $props();
let doubled = $derived(count * 2);
$effect(() => {
console.log('Count changed');
});
</script>
Step 3: Manual Refinements
Some patterns require manual attention:
Store subscriptions:
<!-- Svelte 4 -->
<script>
import { writable } from 'svelte/store';
const count = writable(0);
</script>
<button onclick={() => $count++}>
{$count}
</button>
<!-- Svelte 5 -->
<script>
let count = $state(0);
</script>
<button onclick={() => count++}>
{count}
</button>
Reactive declarations with side effects:
<!-- Svelte 4 -->
<script>
let value = 0;
$: console.log(value);
$: if (value > 10) alert('Too high!');
</script>
<!-- Svelte 5 -->
<script>
let value = $state(0);
$effect(() => {
console.log(value);
if (value > 10) alert('Too high!');
});
</script>
Real-World Application: Todo App with Filtering
Here's a complete example demonstrating Runes in practice:
<script>
class Todo {
id = crypto.randomUUID();
text = $state('');
completed = $state(false);
constructor(text) {
this.text = text;
}
}
class TodoApp {
todos = $state([]);
filter = $state('all');
newTodoText = $state('');
get filteredTodos() {
return $derived.by(() => {
if (this.filter === 'active') {
return this.todos.filter(t => !t.completed);
}
if (this.filter === 'completed') {
return this.todos.filter(t => t.completed);
}
return this.todos;
});
}
get stats() {
return $derived({
total: this.todos.length,
active: this.todos.filter(t => !t.completed).length,
completed: this.todos.filter(t => t.completed).length
});
}
addTodo() {
if (this.newTodoText.trim()) {
this.todos.push(new Todo(this.newTodoText));
this.newTodoText = '';
}
}
removeTodo(id) {
this.todos = this.todos.filter(t => t.id !== id);
}
clearCompleted() {
this.todos = this.todos.filter(t => !t.completed);
}
}
let app = new TodoApp();
// Persist to localStorage
$effect(() => {
const saved = localStorage.getItem('todos');
if (saved) {
try {
const data = JSON.parse(saved);
app.todos = data.map(t => {
const todo = new Todo(t.text);
todo.id = t.id;
todo.completed = t.completed;
return todo;
});
} catch (e) {
console.error('Failed to load todos');
}
}
});
$effect(() => {
localStorage.setItem('todos', JSON.stringify(
app.todos.map(t => ({
id: t.id,
text: t.text,
completed: t.completed
}))
));
});
</script>
<div class="todo-app">
<header>
<h1>todos</h1>
<form onsubmit|preventDefault={() => app.addTodo()}>
<input
bind:value={app.newTodoText}
placeholder="What needs to be done?"
autofocus
/>
</form>
</header>
<section class="main">
<ul class="todo-list">
{#each app.filteredTodos as todo (todo.id)}
<li class:completed={todo.completed}>
<div class="view">
<input
type="checkbox"
bind:checked={todo.completed}
/>
<label>{todo.text}</label>
<button
class="destroy"
onclick={() => app.removeTodo(todo.id)}
>×</button>
</div>
</li>
{/each}
</ul>
</section>
<footer>
<span class="todo-count">
{app.stats.active} {app.stats.active === 1 ? 'item' : 'items'} left
</span>
<ul class="filters">
<li>
<button
class:selected={app.filter === 'all'}
onclick={() => app.filter = 'all'}
>All</button>
</li>
<li>
<button
class:selected={app.filter === 'active'}
onclick={() => app.filter = 'active'}
>Active</button>
</li>
<li>
<button
class:selected={app.filter === 'completed'}
onclick={() => app.filter = 'completed'}
>Completed</button>
</li>
</ul>
{#if app.stats.completed > 0}
<button
class="clear-completed"
onclick={() => app.clearCompleted()}
>
Clear completed
</button>
{/if}
</footer>
</div>
<style>
.todo-app {
max-width: 550px;
margin: 2rem auto;
background: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
header {
padding: 1rem;
}
h1 {
font-size: 4rem;
font-weight: 100;
text-align: center;
color: rgba(175, 47, 47, 0.15);
}
input {
width: 100%;
padding: 1rem;
font-size: 1.5rem;
border: none;
box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03);
}
.todo-list {
list-style: none;
padding: 0;
margin: 0;
}
.todo-list li {
border-bottom: 1px solid #ededed;
padding: 1rem;
display: flex;
align-items: center;
}
.todo-list li.completed label {
text-decoration: line-through;
color: #d9d9d9;
}
.destroy {
margin-left: auto;
background: none;
border: none;
font-size: 2rem;
color: #cc9a9a;
cursor: pointer;
}
footer {
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid #e6e6e6;
}
.filters {
display: flex;
gap: 0.5rem;
list-style: none;
padding: 0;
margin: 0;
}
.filters button {
padding: 0.25rem 0.5rem;
border: 1px solid transparent;
background: none;
cursor: pointer;
}
.filters button.selected {
border-color: rgba(175, 47, 47, 0.2);
}
</style>
Performance Considerations
Svelte 5's Runes system delivers better performance than Svelte 4 through several optimizations:
Fine-Grained Reactivity: Only components that depend on changed state re-render. With Svelte 4, entire component trees could re-render unnecessarily.
Automatic Memoization: $derived() automatically memoizes expensive computations. The value only recalculates when dependencies change.
Reduced Bundle Size: Runes compile to smaller, more efficient JavaScript than Svelte 4's reactive system.
Better Tree-Shaking: The explicit nature of Runes enables better dead code elimination during builds.
Optimization Tips
- Use
$state.raw()for large objects where you don't need deep reactivity - Prefer
$derived()over manual recalculation to benefit from automatic memoization - Keep effects focused - each
$effect()should handle one concern - Avoid creating state in loops - define state at component scope
- Use
$derived.by()for complex derivations to keep logic organized
Conclusion
Svelte 5's Runes represent a significant evolution in reactive state management, providing explicit, predictable primitives that scale from simple components to complex applications. The migration from Svelte 4 is straightforward thanks to backward compatibility and migration tooling, while the performance improvements and developer experience enhancements make the upgrade worthwhile.
Key takeaways:
$state()creates reactive state with deep reactivity by default$derived()computes values efficiently with automatic memoization$effect()handles side effects with proper cleanup$props()and$bindable()modernize component interfaces$inspect()simplifies debugging reactive values
The Runes system's explicit nature makes Svelte 5 code more maintainable, more performant, and easier to reason about than previous versions. As the ecosystem adopts Svelte 5, expect libraries and tools to leverage these powerful primitives for even better developer experiences.
Additional Resources
Related Articles
GraphQL API Design - Production Architecture and Best Practices for Scalable Systems
Master GraphQL API design covering schema design principles, resolver optimization, N+1 query prevention with DataLoader, authentication and authorization patterns, caching strategies, error handling, and production deployment for high-performance GraphQL systems.
Testing Strategies - Unit, Integration, and E2E Testing Best Practices for Production Quality
Comprehensive guide to testing strategies covering unit tests, integration tests, end-to-end testing, test-driven development, mocking patterns, testing pyramid, and production testing practices for reliable software delivery.
Monitoring and Observability - Production Systems Performance and Debugging at Scale
Master monitoring and observability covering metrics collection with Prometheus, distributed tracing with OpenTelemetry, log aggregation, alerting strategies, SLOs/SLIs, and production debugging techniques for reliable systems.
Written by StaticBlock Editorial
StaticBlock Editorial is a technical writer and software engineer specializing in web development, performance optimization, and developer tooling.