0% read
Skip to main content
Svelte 5 Runes: Complete Guide to Reactive State Management

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.

S
StaticBlock Editorial
12 min read

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 =&gt; !item.completed);
  }
  if (this.filter === 'completed') {
    return this.items.filter(item =&gt; 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 =&gt; p.category === selectedCategory);
}

// Filter by price range
result = result.filter(p =&gt; p.price &gt;= minPrice &amp;&amp; p.price &lt;= maxPrice);

// Filter by stock
if (!showOutOfStock) {
  result = result.filter(p =&gt; p.inStock);
}

// Sort by price
return result.sort((a, b) =&gt; 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 &gt;= 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 () =&gt; {
  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 () =&gt; {
  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(() =&gt; {
    if (this.filter === 'active') {
      return this.todos.filter(t =&gt; !t.completed);
    }
    if (this.filter === 'completed') {
      return this.todos.filter(t =&gt; t.completed);
    }
    return this.todos;
  });
}

get stats() {
  return $derived({
    total: this.todos.length,
    active: this.todos.filter(t =&gt; !t.completed).length,
    completed: this.todos.filter(t =&gt; 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 =&gt; t.id !== id);
}

clearCompleted() {
  this.todos = this.todos.filter(t =&gt; !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>

&lt;ul class=&quot;filters&quot;&gt;
  &lt;li&gt;
    &lt;button
      class:selected={app.filter === 'all'}
      onclick={() =&gt; app.filter = 'all'}
    &gt;All&lt;/button&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;button
      class:selected={app.filter === 'active'}
      onclick={() =&gt; app.filter = 'active'}
    &gt;Active&lt;/button&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;button
      class:selected={app.filter === 'completed'}
      onclick={() =&gt; app.filter = 'completed'}
    &gt;Completed&lt;/button&gt;
  &lt;/li&gt;
&lt;/ul&gt;

{#if app.stats.completed &gt; 0}
  &lt;button
    class=&quot;clear-completed&quot;
    onclick={() =&gt; app.clearCompleted()}
  &gt;
    Clear completed
  &lt;/button&gt;
{/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

  1. Use $state.raw() for large objects where you don't need deep reactivity
  2. Prefer $derived() over manual recalculation to benefit from automatic memoization
  3. Keep effects focused - each $effect() should handle one concern
  4. Avoid creating state in loops - define state at component scope
  5. 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

Found this helpful? Share it!

Related Articles

S

Written by StaticBlock Editorial

StaticBlock Editorial is a technical writer and software engineer specializing in web development, performance optimization, and developer tooling.