MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Svelte 5: Runes and Fine-Grained Reactivity

Explore Svelte 5: runes ($state, $derived, $effect), fine-grained reactivity, and migration.

SvelteSvelte 5RunesFrontend

By MinhVo

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.

Svelte 5 Runes Architecture

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.

Fine-Grained Reactivity Diagram

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:

  1. Source nodes ($state): Hold reactive values and notify dependents on change
  2. Computation nodes ($derived): Re-evaluate when sources change, caching results
  3. 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 $state value 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.0

Step 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>

Implementation Patterns

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

  1. Use $state.raw for read-only data: When loading data from APIs that won't be mutated, $state.raw avoids the overhead of deep proxy wrapping. This significantly improves performance for large datasets.

  2. Prefer $derived over $effect for computations: If you're computing a value based on other reactive state, use $derived. Reserve $effect for actual side effects like API calls, DOM manipulation, or logging.

  3. Return cleanup functions from $effect: Every $effect that creates subscriptions, timers, or event listeners should return a cleanup function to prevent memory leaks.

  4. Use .svelte.ts for shared reactive state: Module-level $state in .svelte.ts files creates singleton stores that can be imported across components while maintaining reactivity.

  5. Destructure $props carefully: 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.

  6. Avoid creating $state inside loops: Creating reactive state dynamically in loops creates separate signal nodes for each iteration. Instead, use a single $state array and manipulate it with array methods.

  7. Use $inspect for debugging: The $inspect rune logs reactive values whenever they change, with color-coded console output. It's automatically stripped from production builds.

  8. Keep effects synchronous when possible: Async effects can lead to subtle timing issues. If you need async operations inside an effect, use await inside an immediately-invoked async function.

Common Pitfalls and Solutions

PitfallImpactSolution
Reassigning $state object instead of mutating propertiesLoses fine-grained reactivity; entire object re-rendersMutate properties directly: user.name = 'Bob' instead of user = { ...user, name: 'Bob' }
Forgetting $state on mutable variablesUI doesn't update when values changeAlways use $state for values that affect the UI
Using $effect for derived computationsUnnecessary side effects, potential infinite loopsUse $derived for pure computations; reserve $effect for side effects
Not returning cleanup from $effectMemory leaks from lingering subscriptionsAlways return a cleanup function when creating subscriptions or timers
Destructuring $props outside the declarationProps lose reactivityDestructure inside let { ... } = $props()
Creating $state inside $derivedCreates new signals on every re-evaluationDeclare $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

MetricSvelte 4Svelte 5React 18Vue 3
Initial render (1000 rows)45ms38ms72ms52ms
Single row update0.8ms0.2ms3.2ms0.9ms
Memory usage (10k components)12MB8MB24MB15MB
Bundle size (hello world)2.1KB1.8KB42KB33KB

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

FeatureSvelte 5 RunesReact HooksVue Composition APISolid Signals
Reactivity modelSignals (compiler-optimized)Virtual DOM diffingSignals (Proxy-based)Signals (runtime)
Deep object trackingAutomatic (Proxy)Manual (spread operators)Automatic (Proxy)Manual (accessor functions)
Bundle overhead~2KB runtime~42KB (React + ReactDOM)~33KB runtime~3KB runtime
Learning curveLowMedium (rules of hooks)MediumMedium
TypeScript supportExcellentGood (with setup)ExcellentExcellent
SSR capabilityBuilt-in (SvelteKit)Next.js/RemixBuilt-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:

  1. Runes are compiler-transformed primitives that create optimized signal graphs for fine-grained reactivity
  2. Deep reactivity through Proxies eliminates the need for immutable update patterns
  3. The ownership model ensures automatic cleanup of effects and prevents memory leaks
  4. Migration from Svelte 4 is incremental—both syntaxes can coexist during transition
  5. .svelte.ts modules enable shared reactive state across components
  6. Performance is dramatically improved for updates, with surgical DOM mutations replacing component-level re-renders
  7. 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.