Introduction
Svelte 5 represents the most significant architectural shift in the framework's history. With the introduction of "runes," Svelte moves from an implicit, compiler-driven reactivity model to an explicit, signal-based system that provides fine-grained reactivity without a virtual DOM. This change addresses long-standing developer experience pain points while dramatically improving performance for complex applications.
Runes are special symbols prefixed with $ that the Svelte compiler recognizes as reactive primitives. Unlike React's hooks or Vue's composition API, runes don't require function calls or special wrappers—they look and feel like language primitives while providing compile-time optimizations that eliminate runtime overhead. The four core runes—$state, $derived, $effect, and $props—form a complete reactivity system that replaces Svelte 4's let declarations, $: reactive statements, and export let props.
This guide explores the rune system from first principles, examining how Svelte 5's signal-based architecture delivers superior performance through surgical DOM updates, walks through practical migration strategies from Svelte 4, and demonstrates advanced patterns for building production applications.
Understanding Runes: The Core Reactivity Model
What Are Signals?
Before diving into runes, it's essential to understand signals—the reactive primitive underlying Svelte 5. A signal is a container for a value that automatically tracks which computations depend on it. When the signal's value changes, only the dependent computations re-execute, not the entire component tree.
Unlike React's state management, where setState triggers a full component re-render and reconciliation through the virtual DOM, signals enable surgical updates. When you change a $state variable, Svelte's compiler knows exactly which DOM elements reference that variable and updates only those specific text nodes, attributes, or event handlers.
This architectural difference has profound performance implications. In a complex dashboard with hundreds of data points, changing a single counter won't cause the entire component tree to re-evaluate. The signal graph ensures that only the minimum necessary DOM mutations occur.
The Four Pillars of Runes
$state declares reactive mutable state. The compiler transforms $state(initialValue) into a signal that tracks both reads and writes. For primitive values, it creates a simple signal. For objects and arrays, it creates a deeply reactive proxy that tracks nested property access and mutations.
$derived creates computed values that automatically update when their dependencies change. This replaces both $: reactive declarations and the need for memoization hooks. The compiler analyzes the expression inside $derived() to build a dependency graph, ensuring the derived value recalculates only when necessary.
$effect runs side effects in response to state changes. Unlike $derived, which is for pure computations, $effect handles operations like API calls, DOM manipulation, logging, and localStorage synchronization. Effects run after the DOM updates, ensuring they see the latest state.
$props declares component props with full TypeScript support. It replaces export let and provides default values, type inference, and the ability to destructure props without losing reactivity.
Deep Reactivity
One of Svelte 5's most powerful features is automatic deep reactivity. When you create $state with an object or array, Svelte creates a recursive proxy that tracks mutations at any nesting depth:
<script>
let user = $state({
name: 'Alice',
preferences: {
theme: 'dark',
notifications: { email: true, push: false }
},
tags: ['admin', 'developer']
});
// All of these trigger updates automatically:
user.name = 'Bob';
user.preferences.theme = 'light';
user.preferences.notifications.push = true;
user.tags.push('editor');
</script>This eliminates the need for immutable update patterns that plague React development. You mutate state directly, and Svelte handles the reactivity tracking at the proxy level.
Architecture and Design Patterns
Signal Graph and Dependency Tracking
Svelte 5's compiler builds a static dependency graph at compile time. When it encounters a $state declaration followed by expressions that reference it, it generates code that registers those dependencies at runtime. The signal graph consists of three node types:
- Source nodes (
$state): Hold reactive values and notify dependents on change - Computation nodes (
$derived): Re-evaluate when sources change, caching results - Effect nodes (
$effect): Execute side effects when dependencies change
The graph is topologically sorted—when a source changes, derived values update before effects run, ensuring effects see consistent derived state.
The Ownership Model
Svelte 5 introduces an ownership model that determines the lifecycle and cleanup of reactive state. When a component creates $state or $effect, that component "owns" those primitives. When the component unmounts, all owned effects are automatically cleaned up, preventing memory leaks.
<script>
// This component owns the state and effect
let count = $state(0);
$effect(() => {
const interval = setInterval(() => {
count++;
}, 1000);
// Cleanup runs when component unmounts
return () => clearInterval(interval);
});
</script>Ownership can be transferred through the reactive module pattern, where .svelte.ts files create state that outlives individual components but is still managed by the module's lifecycle.
Compiler Optimizations
The Svelte 5 compiler performs several optimizations that pure runtime signal libraries cannot:
- Signal pruning: If a
$statevalue is never read in templates or effects, the compiler eliminates the signal entirely - Static hoisting: Constant derived values are computed once at module load time
- Inline effects: Simple effects are inlined into the update path, avoiding the overhead of the effect scheduler
- Proxy elision: For objects that are never mutated after creation, the compiler skips proxy wrapping
Step-by-Step Implementation
Migrating from Svelte 4
Migrating to runes is incremental—Svelte 5 supports both old and new syntax simultaneously. Here's a systematic approach:
Step 1: Update Dependencies
npm install svelte@^5.0.0 @sveltejs/vite-plugin-svelte@^3.0.0Step 2: Convert Component State
<!-- Svelte 4 -->
<script>
let count = 0;
let doubled = 0;
$: doubled = count * 2;
function increment() {
count += 1;
}
</script>
<!-- Svelte 5 (runes mode) -->
<script>
let count = $state(0);
let doubled = $derived(count * 2);
function increment() {
count++;
}
</script>Step 3: Convert Props
<!-- Svelte 4 -->
<script>
export let name;
export let count = 0;
</script>
<!-- Svelte 5 -->
<script>
let { name, count = 0 } = $props();
</script>Step 4: Convert Reactive Statements
<!-- Svelte 4 -->
<script>
let items = [];
$: total = items.reduce((sum, i) => sum + i.price, 0);
$: console.log('Total changed:', total);
</script>
<!-- Svelte 5 -->
<script>
let items = $state([]);
let total = $derived(items.reduce((sum, i) => sum + i.price, 0));
$effect(() => {
console.log('Total changed:', total);
});
</script>Building a Reactive Store
Create reusable reactive state using .svelte.ts modules:
// src/lib/stores/cart.svelte.ts
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
// Module-level state (singleton)
let items = $state<CartItem[]>([]);
let discount = $state(0);
export function getCart() {
const subtotal = $derived(
items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
const total = $derived(subtotal * (1 - discount / 100));
const itemCount = $derived(
items.reduce((sum, item) => sum + item.quantity, 0)
);
function addItem(item: Omit<CartItem, 'quantity'>) {
const existing = items.find(i => i.id === item.id);
if (existing) {
existing.quantity++;
} else {
items.push({ ...item, quantity: 1 });
}
}
function removeItem(id: string) {
const index = items.findIndex(i => i.id === id);
if (index !== -1) items.splice(index, 1);
}
function updateQuantity(id: string, quantity: number) {
const item = items.find(i => i.id === id);
if (item) {
if (quantity <= 0) {
removeItem(id);
} else {
item.quantity = quantity;
}
}
}
return {
get items() { return items; },
get subtotal() { return subtotal; },
get total() { return total; },
get itemCount() { return itemCount; },
get discount() { return discount; },
set discount(value) { discount = value; },
addItem,
removeItem,
updateQuantity,
clear() { items = []; }
};
}Creating Type-Safe Components
<!-- src/lib/components/CartItem.svelte -->
<script lang="ts">
import type { CartItem } from '$lib/stores/cart.svelte';
let { item, onUpdateQuantity, onRemove }: {
item: CartItem;
onUpdateQuantity: (id: string, quantity: number) => void;
onRemove: (id: string) => void;
} = $props();
</script>
<div class="cart-item">
<div class="item-info">
<h3>{item.name}</h3>
<p class="price">${item.price.toFixed(2)}</p>
</div>
<div class="quantity-controls">
<button onclick={() => onUpdateQuantity(item.id, item.quantity - 1)}>−</button>
<span>{item.quantity}</span>
<button onclick={() => onUpdateQuantity(item.id, item.quantity + 1)}>+</button>
</div>
<p class="line-total">${(item.price * item.quantity).toFixed(2)}</p>
<button class="remove" onclick={() => onRemove(item.id)}>×</button>
</div>Real-World Use Cases
Use Case 1: Real-Time Dashboard
Svelte 5's fine-grained reactivity excels in scenarios where only small portions of the UI update frequently:
<script lang="ts">
let metrics = $state({
cpu: 0,
memory: 0,
requests: 0,
errors: 0,
uptime: 0
});
let errorRate = $derived(
metrics.requests > 0
? ((metrics.errors / metrics.requests) * 100).toFixed(2)
: '0.00'
);
let cpuStatus = $derived(
metrics.cpu > 90 ? 'critical' : metrics.cpu > 70 ? 'warning' : 'normal'
);
$effect(() => {
const ws = new WebSocket('wss://api.example.com/metrics');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
Object.assign(metrics, data);
};
return () => ws.close();
});
</script>
<div class="dashboard">
<div class="metric" class:critical={cpuStatus === 'critical'}>
<span class="label">CPU</span>
<span class="value">{metrics.cpu}%</span>
</div>
<div class="metric">
<span class="label">Memory</span>
<span class="value">{metrics.memory}MB</span>
</div>
<div class="metric">
<span class="label">Error Rate</span>
<span class="value">{errorRate}%</span>
</div>
</div>Use Case 2: Multi-Step Form with Validation
Runes make complex form logic readable and maintainable:
<script lang="ts">
let step = $state(1);
let formData = $state({
personal: { firstName: '', lastName: '', email: '' },
address: { street: '', city: '', state: '', zip: '' },
payment: { cardNumber: '', expiry: '', cvv: '' }
});
let isStep1Valid = $derived(
formData.personal.firstName.length > 0 &&
formData.personal.email.includes('@')
);
let isStep2Valid = $derived(
formData.address.street.length > 0 &&
formData.address.zip.match(/^\d{5}$/) !== null
);
let isStep3Valid = $derived(
formData.payment.cardNumber.replace(/\s/g, '').length === 16 &&
formData.payment.cvv.length >= 3
);
let canProceed = $derived(
(step === 1 && isStep1Valid) ||
(step === 2 && isStep2Valid) ||
(step === 3 && isStep3Valid)
);
async function submit() {
const response = await fetch('/api/orders', {
method: 'POST',
body: JSON.stringify(formData)
});
// Handle response
}
</script>Use Case 3: Drag-and-Drop Kanban Board
Deep reactivity makes drag-and-drop interactions straightforward:
<script lang="ts">
let columns = $state([
{
id: 'todo',
title: 'To Do',
cards: [
{ id: '1', title: 'Design system', assignee: 'Alice' },
{ id: '2', title: 'API integration', assignee: 'Bob' }
]
},
{ id: 'progress', title: 'In Progress', cards: [] },
{ id: 'done', title: 'Done', cards: [] }
]);
let draggedCard = $state<{ card: any; sourceColumn: string } | null>(null);
function handleDragStart(card: any, columnId: string) {
draggedCard = { card, sourceColumn: columnId };
}
function handleDrop(targetColumnId: string) {
if (!draggedCard) return;
const source = columns.find(c => c.id === draggedCard!.sourceColumn);
const target = columns.find(c => c.id === targetColumnId);
if (source && target) {
source.cards = source.cards.filter(c => c.id !== draggedCard!.card.id);
target.cards.push(draggedCard!.card);
}
draggedCard = null;
}
</script>Best Practices for Production
-
Use
$state.rawfor read-only data: When loading data from APIs that won't be mutated,$state.rawavoids the overhead of deep proxy wrapping. This significantly improves performance for large datasets. -
Prefer
$derivedover$effectfor computations: If you're computing a value based on other reactive state, use$derived. Reserve$effectfor actual side effects like API calls, DOM manipulation, or logging. -
Return cleanup functions from
$effect: Every$effectthat creates subscriptions, timers, or event listeners should return a cleanup function to prevent memory leaks. -
Use
.svelte.tsfor shared reactive state: Module-level$statein.svelte.tsfiles creates singleton stores that can be imported across components while maintaining reactivity. -
Destructure
$propscarefully: When destructuring props, use the$props()rune directly. The compiler tracks each destructured property independently, so parent changes to any prop will trigger updates in the child. -
Avoid creating
$stateinside loops: Creating reactive state dynamically in loops creates separate signal nodes for each iteration. Instead, use a single$statearray and manipulate it with array methods. -
Use
$inspectfor debugging: The$inspectrune logs reactive values whenever they change, with color-coded console output. It's automatically stripped from production builds. -
Keep effects synchronous when possible: Async effects can lead to subtle timing issues. If you need async operations inside an effect, use
awaitinside an immediately-invoked async function.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
Reassigning $state object instead of mutating properties | Loses fine-grained reactivity; entire object re-renders | Mutate properties directly: user.name = 'Bob' instead of user = { ...user, name: 'Bob' } |
Forgetting $state on mutable variables | UI doesn't update when values change | Always use $state for values that affect the UI |
Using $effect for derived computations | Unnecessary side effects, potential infinite loops | Use $derived for pure computations; reserve $effect for side effects |
Not returning cleanup from $effect | Memory leaks from lingering subscriptions | Always return a cleanup function when creating subscriptions or timers |
Destructuring $props outside the declaration | Props lose reactivity | Destructure inside let { ... } = $props() |
Creating $state inside $derived | Creates new signals on every re-evaluation | Declare $state at the top level; only reference it inside $derived |
Performance Optimization
Svelte 5's signal-based architecture provides several performance advantages over Svelte 4 and competing frameworks:
Benchmark Comparison
| Metric | Svelte 4 | Svelte 5 | React 18 | Vue 3 |
|---|---|---|---|---|
| Initial render (1000 rows) | 45ms | 38ms | 72ms | 52ms |
| Single row update | 0.8ms | 0.2ms | 3.2ms | 0.9ms |
| Memory usage (10k components) | 12MB | 8MB | 24MB | 15MB |
| Bundle size (hello world) | 2.1KB | 1.8KB | 42KB | 33KB |
The dramatic improvement in single-row updates comes from Svelte 5's ability to update only the specific text node that references the changed state, rather than re-evaluating the entire component.
Optimization Strategies
<script>
// Use $state.raw for large read-only datasets
let articles = $state.raw(fetchArticles());
// Use keyed each blocks for efficient list updates
// The key expression tells Svelte how to match old and new items
</script>
{#each articles as article (article.id)}
<ArticleCard {article} />
{/each}
<script>
// Avoid creating reactive state in templates
// Bad: creates a new signal each render
// {#each items as item}
// {@const doubled = $derived(item.value * 2)} <!-- Wrong! -->
// {/each}
// Good: compute in the script block
let doubledItems = $derived(
items.map(item => ({ ...item, doubled: item.value * 2 }))
);
</script>Comparison with Alternatives
| Feature | Svelte 5 Runes | React Hooks | Vue Composition API | Solid Signals |
|---|---|---|---|---|
| Reactivity model | Signals (compiler-optimized) | Virtual DOM diffing | Signals (Proxy-based) | Signals (runtime) |
| Deep object tracking | Automatic (Proxy) | Manual (spread operators) | Automatic (Proxy) | Manual (accessor functions) |
| Bundle overhead | ~2KB runtime | ~42KB (React + ReactDOM) | ~33KB runtime | ~3KB runtime |
| Learning curve | Low | Medium (rules of hooks) | Medium | Medium |
| TypeScript support | Excellent | Good (with setup) | Excellent | Excellent |
| SSR capability | Built-in (SvelteKit) | Next.js/Remix | Built-in (Nuxt) | SolidStart |
Svelte 5's key differentiator is that it achieves signal-level performance while keeping the developer experience of writing straightforward imperative code. You don't need to wrap values in accessor functions (Solid) or follow rules about hook ordering (React).
Advanced Patterns
Composable Runes (Reusable Reactive Logic)
Create reusable reactive behaviors that can be composed across components:
// src/lib/runes/useDebounce.svelte.ts
export function useDebounce<T>(value: () => T, delay: number) {
let debounced = $state(value());
$effect(() => {
const timeout = setTimeout(() => {
debounced = value();
}, delay);
return () => clearTimeout(timeout);
});
return {
get current() { return debounced; }
};
}
// src/lib/runes/useLocalStorage.svelte.ts
export function useLocalStorage<T>(key: string, initialValue: T) {
const stored = typeof localStorage !== 'undefined'
? JSON.parse(localStorage.getItem(key) ?? 'null') ?? initialValue
: initialValue;
let value = $state<T>(stored);
$effect(() => {
localStorage.setItem(key, JSON.stringify(value));
});
return {
get current() { return value; },
set current(v: T) { value = v; }
};
}Reactive Context for Dependency Injection
// src/lib/context/auth.svelte.ts
import { setContext, getContext } from 'svelte';
export function createAuthContext() {
let user = $state<User | null>(null);
let token = $state<string | null>(null);
const isAuthenticated = $derived(user !== null && token !== null);
async function login(email: string, password: string) {
const response = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password })
});
const data = await response.json();
user = data.user;
token = data.token;
}
function logout() {
user = null;
token = null;
}
const auth = {
get user() { return user; },
get token() { return token; },
get isAuthenticated() { return isAuthenticated; },
login,
logout
};
setContext('auth', auth);
return auth;
}
export function getAuth() {
return getContext<ReturnType<typeof createAuthContext>>('auth');
}Testing Strategies
Testing rune-based components requires the @testing-library/svelte v5+:
// src/lib/components/Counter.test.ts
import { render, screen, fireEvent } from '@testing-library/svelte';
import { describe, it, expect } from 'vitest';
import Counter from './Counter.svelte';
describe('Counter', () => {
it('increments count on button click', async () => {
render(Counter, { initialCount: 0 });
const button = screen.getByRole('button', { name: 'Increment' });
const display = screen.getByTestId('count-display');
expect(display).toHaveTextContent('0');
await fireEvent.click(button);
expect(display).toHaveTextContent('1');
await fireEvent.click(button);
expect(display).toHaveTextContent('2');
});
it('computes doubled value reactively', async () => {
render(Counter, { initialCount: 5 });
const doubled = screen.getByTestId('doubled-display');
expect(doubled).toHaveTextContent('10');
await fireEvent.click(screen.getByRole('button', { name: 'Increment' }));
expect(doubled).toHaveTextContent('12');
});
});Future Outlook
Svelte 5's runes system opens several exciting possibilities. The signal graph enables potential optimizations like automatic code-splitting at reactive boundaries, where the compiler could split bundles based on which signals are used where. Server components could stream signal updates to the client without full re-renders.
The ecosystem is rapidly adopting runes. SvelteKit 2.0 provides full runes support with type-safe routing and server-side data loading. Libraries like Skeleton UI, Melt UI, and Bits UI have released runes-compatible versions. The community is building new patterns for state management, form handling, and data fetching that leverage the signal architecture.
The Svelte team is also exploring "universal reactivity"—the ability to use runes outside of Svelte components in any JavaScript context. This would enable sharing reactive state between Svelte components, vanilla JavaScript, and even other frameworks through adapter libraries.
Conclusion
Svelte 5's runes represent a paradigm shift that makes reactive programming explicit, performant, and type-safe. The move from implicit $: reactive declarations to explicit $state, $derived, $effect, and $props runes provides better developer experience while enabling compiler optimizations that deliver superior runtime performance.
Key takeaways:
- Runes are compiler-transformed primitives that create optimized signal graphs for fine-grained reactivity
- Deep reactivity through Proxies eliminates the need for immutable update patterns
- The ownership model ensures automatic cleanup of effects and prevents memory leaks
- Migration from Svelte 4 is incremental—both syntaxes can coexist during transition
.svelte.tsmodules enable shared reactive state across components- Performance is dramatically improved for updates, with surgical DOM mutations replacing component-level re-renders
- TypeScript integration is first-class with full type inference for props, state, and derived values
Whether you're building a new application or migrating an existing Svelte project, runes provide a solid foundation for reactive UI development. The explicit nature of the API makes code easier to understand, debug, and maintain, while the compiler optimizations ensure excellent runtime performance.
For further learning, consult the Svelte 5 documentation, explore the interactive tutorial, and join the Svelte Discord community.