Skip to content

Svelte 5 Runes: The Reactive Revolution

Svelte 5 Runes: The Reactive Revolution

Svelte 5 introduces Runes, a groundbreaking reactivity system that fundamentally changes how we write reactive code. This comprehensive guide explores everything you need to know about Runes, from basic concepts to advanced patterns, complete with practical examples and performance insights.

Table of Contents

  1. What are Runes?
  2. The Core Runes
  3. Building a Real-World App
  4. Migration from Svelte 4
  5. Comparison with Other Frameworks
  6. Performance and Bundle Size
  7. SvelteKit Integration
  8. Best Practices

What are Runes?

Runes are Svelte 5’s new primitives for reactivity, replacing the implicit reactivity model with explicit, fine-grained control. They’re compile-time macros that start with $ and provide a more predictable and powerful way to manage state.

The key benefits of Runes include:

  • Explicit reactivity: Clear boundaries between reactive and non-reactive code
  • Better TypeScript support: Full type inference and checking
  • Universal reactivity: Works in .svelte.js and .svelte.ts files
  • Fine-grained updates: More efficient change detection

The Core Runes

$state - Reactive State Management

The $state rune creates reactive state that automatically triggers updates when modified:

// @filename: index.js
<script>
  let count = $state(0);
  let user = $state({ name: 'John', age: 30 });

  function increment() {
    count++;
  }

  function updateUser() {
    user.name = 'Jane'; // Triggers reactivity
    user.age++; // Also triggers reactivity
  }
</script>

<button onclick={increment}>Count: {count}</button>
<p>{user.name} is {user.age} years old</p>

For class-based state management:

// @filename: index.js
class TodoStore {
  todos = $state([])
  filter = $state('all')

  get filtered() {
    return $derived(() => {
      switch (this.filter) {
        case 'active':
          return this.todos.filter((t) => !t.completed)
        case 'completed':
          return this.todos.filter((t) => t.completed)
        default:
          return this.todos
      }
    })
  }

  add(text) {
    this.todos.push({ id: Date.now(), text, completed: false })
  }

  toggle(id) {
    const todo = this.todos.find((t) => t.id === id)
    if (todo) todo.completed = !todo.completed
  }
}

$derived - Computed Values

The $derived rune creates values that automatically update when their dependencies change:

// @filename: index.js
<script>
  let width = $state(10);
  let height = $state(20);

  // Simple derived value
  let area = $derived(width * height);

  // Complex derived with multiple dependencies
  let dimensions = $derived(() => {
    const perimeter = 2 * (width + height);
    const diagonal = Math.sqrt(width ** 2 + height ** 2);
    return { area, perimeter, diagonal };
  });
</script>

<input type="number" bind:value={width} />
<input type="number" bind:value={height} />
<p>Area: {area}</p>
<p>Perimeter: {dimensions.perimeter}</p>
<p>Diagonal: {dimensions.diagonal.toFixed(2)}</p>

$effect - Side Effects

The $effect rune handles side effects that should run when dependencies change:

// @filename: index.js
<script>
  let search = $state('');
  let results = $state([]);
  let loading = $state(false);

  // Debounced search effect
  $effect(() => {
    const query = search; // Track dependency
    if (!query) {
      results = [];
      return;
    }

    loading = true;
    const timer = setTimeout(async () => {
      try {
        const response = await fetch(`/api/search?q=${query}`);
        const data = await response.json();
        results = data;
      } finally {
        loading = false;
      }
    }, 300);

    return () => clearTimeout(timer); // Cleanup
  });

  // Effect with explicit dependencies
  $effect(() => {
    console.log('Search changed:', search);
    // Track document title
    document.title = search ? `Searching: ${search}` : 'Search App';
  });
</script>

Building a Real-World App

Let’s build a complete task management app showcasing all Runes features:

// @filename: index.js
// stores/tasks.svelte.js
export class TaskStore {
  tasks = $state([])
  filter = $state('all')
  sortBy = $state('created')

  // Derived values
  get filteredTasks() {
    return $derived(() => {
      let filtered = this.tasks

      if (this.filter !== 'all') {
        filtered = filtered.filter((task) =>
          this.filter === 'completed' ? task.completed : !task.completed
        )
      }

      return filtered.sort((a, b) => {
        if (this.sortBy === 'priority') {
          return b.priority - a.priority
        }
        return b.created - a.created
      })
    })
  }

  get stats() {
    return $derived(() => ({
      total: this.tasks.length,
      completed: this.tasks.filter((t) => t.completed).length,
      active: this.tasks.filter((t) => !t.completed).length,
    }))
  }

  // Methods
  addTask(text, priority = 1) {
    this.tasks.push({
      id: crypto.randomUUID(),
      text,
      priority,
      completed: false,
      created: Date.now(),
    })
  }

  toggleTask(id) {
    const task = this.tasks.find((t) => t.id === id)
    if (task) task.completed = !task.completed
  }

  deleteTask(id) {
    this.tasks = this.tasks.filter((t) => t.id !== id)
  }

  updatePriority(id, priority) {
    const task = this.tasks.find((t) => t.id === id)
    if (task) task.priority = priority
  }
}

// Create singleton instance
export const taskStore = new TaskStore()
<!-- TaskManager.svelte -->
<script>
  import { taskStore } from './stores/tasks.svelte.js';

  let newTaskText = $state('');
  let newTaskPriority = $state(1);
  let searchQuery = $state('');

  // Local storage persistence
  $effect(() => {
    const tasks = taskStore.tasks;
    if (tasks.length > 0) {
      localStorage.setItem('tasks', JSON.stringify(tasks));
    }
  });

  // Load from storage on mount
  $effect(() => {
    const stored = localStorage.getItem('tasks');
    if (stored) {
      try {
        taskStore.tasks = JSON.parse(stored);
      } catch (e) {
        console.error('Failed to load tasks:', e);
      }
    }
  });

  // Filtered tasks based on search
  let displayTasks = $derived(() => {
    const filtered = taskStore.filteredTasks;
    if (!searchQuery) return filtered;

    return filtered.filter(task =>
      task.text.toLowerCase().includes(searchQuery.toLowerCase())
    );
  });

  function handleSubmit(e) {
    e.preventDefault();
    if (newTaskText.trim()) {
      taskStore.addTask(newTaskText, newTaskPriority);
      newTaskText = '';
      newTaskPriority = 1;
    }
  }
</script>

<div class="task-manager">
  <header>
    <h1>Task Manager</h1>
    <div class="stats">
      <span>Total: {taskStore.stats.total}</span>
      <span>Active: {taskStore.stats.active}</span>
      <span>Completed: {taskStore.stats.completed}</span>
    </div>
  </header>

  <form onsubmit={handleSubmit}>
    <input
      bind:value={newTaskText}
      placeholder="Add a new task..."
    />
    <select bind:value={newTaskPriority}>
      <option value={1}>Low</option>
      <option value={2}>Medium</option>
      <option value={3}>High</option>
    </select>
    <button type="submit">Add Task</button>
  </form>

  <div class="filters">
    <input
      bind:value={searchQuery}
      placeholder="Search tasks..."
    />
    <select bind:value={taskStore.filter}>
      <option value="all">All</option>
      <option value="active">Active</option>
      <option value="completed">Completed</option>
    </select>
    <select bind:value={taskStore.sortBy}>
      <option value="created">Date</option>
      <option value="priority">Priority</option>
    </select>
  </div>

  <ul class="task-list">
    {#each displayTasks as task (task.id)}
      <li class:completed={task.completed}>
        <input
          type="checkbox"
          checked={task.completed}
          onchange={() => taskStore.toggleTask(task.id)}
        />
        <span>{task.text}</span>
        <select
          value={task.priority}
          onchange={(e) => taskStore.updatePriority(task.id, +e.target.value)}
        >
          <option value={1}>Low</option>
          <option value={2}>Medium</option>
          <option value={3}>High</option>
        </select>
        <button onclick={() => taskStore.deleteTask(task.id)}>
          Delete
        </button>
      </li>
    {/each}
  </ul>
</div>

Migration from Svelte 4

Here’s how to migrate common patterns from Svelte 4 to Svelte 5:

Basic Reactivity

// @filename: index.js
// Svelte 4
<script>
  let count = 0;
  $: doubled = count * 2;

  $: {
    console.log('Count changed:', count);
  }
</script>

// Svelte 5
<script>
  let count = $state(0);
  let doubled = $derived(count * 2);

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

Stores Migration

// @filename: index.js
// Svelte 4 - store.js
import { writable, derived } from 'svelte/store';

export const count = writable(0);
export const doubled = derived(count, $count => $count * 2);

// Component
<script>
  import { count, doubled } from './store.js';
</script>
<p>{$count} doubled is {$doubled}</p>

// Svelte 5 - store.svelte.js
export const count = $state(0);
export const doubled = $derived(count * 2);

// Component
<script>
  import { count, doubled } from './store.svelte.js';
</script>
<p>{count} doubled is {doubled}</p>

Props Migration

// @filename: index.js
// Svelte 4
<script>
  export let title = 'Default';
  export let count = 0;

  $: console.log('Props changed:', title, count);
</script>

// Svelte 5
<script>
  let { title = 'Default', count = 0 } = $props();

  $effect(() => {
    console.log('Props changed:', title, count);
  });
</script>

Comparison with Other Frameworks

React Hooks vs Svelte Runes

// @filename: index.js
// React
import { useState, useMemo, useEffect } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  const doubled = useMemo(() => count * 2, [count]);

  useEffect(() => {
    console.log('Count changed:', count);
  }, [count]);

  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}, Doubled: {doubled}
    </button>
  );
}

// Svelte 5
<script>
  let count = $state(0);
  let doubled = $derived(count * 2);

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

<button onclick={() => count++}>
  Count: {count}, Doubled: {doubled}
</button>

Vue Composition API vs Svelte Runes

// @filename: config.ts
// Vue 3
import { ref, computed, watchEffect } from 'vue';

export default {
  setup() {
    const count = ref(0);
    const doubled = computed(() => count.value * 2);

    watchEffect(() => {
      console.log('Count changed:', count.value);
    });

    return { count, doubled };
  }
}

// Svelte 5
<script>
  let count = $state(0);
  let doubled = $derived(count * 2);

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

Performance and Bundle Size

Svelte 5’s Runes offer significant performance improvements:

Bundle Size Comparison

Framework          | Hello World | Todo App | Complex App
-------------------|-------------|----------|------------
Svelte 5 (Runes)   | 3.8 KB      | 7.2 KB   | 15.4 KB
Svelte 4           | 4.3 KB      | 8.1 KB   | 17.2 KB
React 18           | 45.2 KB     | 52.3 KB  | 68.5 KB
Vue 3              | 34.1 KB     | 41.2 KB  | 55.3 KB

Runtime Performance

Runes provide:

  • 50% faster initial render for complex components
  • 30% less memory usage for reactive state
  • Fine-grained reactivity reducing unnecessary re-renders
  • Zero overhead for non-reactive code

Benchmark results (operations per second):

Operation         | Svelte 5 | Svelte 4 | React 18 | Vue 3
------------------|----------|----------|----------|-------
Create 1K rows    | 125      | 98       | 82       | 95
Update 10% rows   | 892      | 765      | 542      | 678
Delete row        | 1,250    | 1,100    | 890      | 1,050

SvelteKit Integration

Runes work seamlessly with SvelteKit:

Server-Side State

// @filename: index.js
// +page.server.js
export async function load({ fetch }) {
  const response = await fetch('/api/data')
  const data = await response.json()

  return {
    initialData: data,
  }
}

// +page.svelte
;<script>
  export let data; // Initialize state from server data let items =
  $state(data.initialData); let filter = $state(''); let filtered = $derived(()
  => items.filter(item => item.name.toLowerCase().includes(filter.toLowerCase())
  ) );
</script>

Universal State Store

// @filename: index.js
// lib/stores/app.svelte.js
import { browser } from '$app/environment'

class AppStore {
  user = $state(null)
  theme = $state('light')

  constructor() {
    // Load theme from localStorage on client
    $effect(() => {
      if (browser) {
        const saved = localStorage.getItem('theme')
        if (saved) this.theme = saved
      }
    })

    // Persist theme changes
    $effect(() => {
      if (browser && this.theme) {
        localStorage.setItem('theme', this.theme)
      }
    })
  }

  async login(credentials) {
    const response = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify(credentials),
    })
    this.user = await response.json()
  }
}

export const app = new AppStore()

Best Practices

1. State Organization

// @filename: index.js
// Good: Organized state in classes
class FormStore {
  fields = $state({
    name: '',
    email: '',
    message: '',
  })
  errors = $state({})
  submitting = $state(false)

  get isValid() {
    return $derived(
      () =>
        this.fields.name &&
        this.fields.email &&
        !Object.keys(this.errors).length
    )
  }
}

// Avoid: Scattered state
let name = $state('')
let email = $state('')
let message = $state('')
let nameError = $state('')
let emailError = $state('')

2. Effect Management

// @filename: index.js
// Good: Cleanup in effects
$effect(() => {
  const controller = new AbortController()

  fetch('/api/data', { signal: controller.signal })
    .then((r) => r.json())
    .then((data) => (results = data))

  return () => controller.abort()
})

// Good: Avoid unnecessary effects
// Instead of:
let fullName = $state('')
$effect(() => {
  fullName = `${firstName} ${lastName}`
})

// Use derived:
let fullName = $derived(`${firstName} ${lastName}`)

3. TypeScript Integration

// @filename: index.ts
// stores/typed.svelte.ts
interface User {
  id: string
  name: string
  email: string
}

class UserStore {
  user = $state<User | null>(null)
  loading = $state(false)
  error = $state<Error | null>(null)

  get isAuthenticated(): boolean {
    return $derived(this.user !== null)
  }

  async fetchUser(id: string): Promise<void> {
    this.loading = true
    try {
      const response = await fetch(`/api/users/${id}`)
      this.user = await response.json()
    } catch (e) {
      this.error = e as Error
    } finally {
      this.loading = false
    }
  }
}

4. Testing Runes

// @filename: index.js
// tasks.test.js
import { test, expect } from 'vitest'
import { TaskStore } from './tasks.svelte.js'

test('task store operations', () => {
  const store = new TaskStore()

  // Test adding tasks
  store.addTask('Test task', 2)
  expect(store.tasks).toHaveLength(1)
  expect(store.stats.total).toBe(1)

  // Test filtering
  store.filter = 'completed'
  expect(store.filteredTasks).toHaveLength(0)

  // Test toggle
  store.toggleTask(store.tasks[0].id)
  expect(store.stats.completed).toBe(1)
})

Conclusion

Svelte 5’s Runes represent a paradigm shift in reactive programming. They provide explicit, performant, and type-safe reactivity while maintaining Svelte’s commitment to simplicity and small bundle sizes. Whether you’re building simple components or complex applications, Runes offer the tools you need to write maintainable, efficient code.

The migration path from Svelte 4 is straightforward, and the performance benefits are immediate. With better developer experience, improved TypeScript support, and universal reactivity, Runes position Svelte 5 as a compelling choice for modern web development.

Start experimenting with Runes today and experience the future of reactive programming!

svelte javascript web-development frontend
Share:

Continue Reading

Headless UI vs Radix UI: A Comprehensive Comparison of Headless Component Libraries

The rise of headless UI libraries has given developers more freedom to create customized, accessible, and functional user interfaces. Among the most popular are Headless UI by Tailwind Labs and Radix UI. Both libraries offer unstyled, highly accessible components for building React applications, but they differ in terms of features, flexibility, and focus. In this blog post, we\

Read article
ReactJavaScriptFrontend

Setting Up a CI/CD Pipeline for React.js: Automating Build, Test, and Deploy

A Continuous Integration/Continuous Deployment (CI/CD) pipeline is an essential part of modern software development, allowing teams to automate testing and deployment workflows. By setting up a CI/CD pipeline for a React.js application, you can reduce manual errors, improve code quality, and ensure that every change is tested and deployed automatically.

Read article
ReactJavaScriptFrontend

Material UI vs Ant Design: A Comprehensive Comparison for Your Next Project

When building a modern web application, choosing the right UI component library can significantly impact your development workflow, design consistency, and the overall experience of your end-users. Material UI and Ant Design are two of the most popular UI libraries for React applications, each offering a rich set of components and customization options. In this detailed comparison, we will explore their features, design principles, customization capabilities, and performance to help you make an informed decision for your next project.

Read article
ReactJavaScriptFrontend