For years, Svelte has been the darling of the web development world because of its simplicity. Unlike React, which requires a deep understanding of hooks and the virtual DOM, Svelte 3 and 4 allowed developers to write code that felt like “just JavaScript.” You declared a variable with let, and it was reactive. You used the $: label, and things updated magically. It was, and still is, a breath of fresh air.
However, as applications grew in complexity, some cracks began to show. The $: syntax, while clever, felt like a hack of the JavaScript label system. Reactivity was tied strictly to the component’s top-level scope, making it difficult to share reactive logic across different files without reaching for “Stores.” Svelte’s compiler-based approach, while fast, sometimes led to confusing edge cases where developers weren’t quite sure when or why a component was re-rendering.
Enter Svelte 5 and Runes. This is not just a minor update; it is a fundamental shift in how Svelte handles reactivity. By moving from a “compiler-tricked” reactivity model to a Signals-based model, Svelte 5 provides more power, better performance, and a much cleaner developer experience. In this comprehensive guide, we will explore the core Runes, how they solve real-world problems, and how you can transition your mindset to this new paradigm.
The Problem: Why Did Svelte Change?
Before we dive into the code, we must understand the “Why.” In Svelte 4, reactivity was triggered by assignments. If you changed a property inside an object, Svelte wouldn’t necessarily know that the object had changed unless you reassigned the object itself (the famous obj = obj trick). Furthermore, sharing reactive state between a .js file and a .svelte file required the use of Svelte Stores (writable, derived, readable).
While Stores are powerful, they introduce a different syntax (the $ prefix for subscribing) and a different mental model. Svelte 5 aims to unify this. Whether you are in a component file or a standard JavaScript file, reactivity should look and behave the same. Runes achieve this by making reactivity an explicit part of the language via special functions (Runes) that the compiler understands.
What Are Runes?
Runes are special symbols that provide instructions to the Svelte compiler. They look like functions, but they are actually built-in primitives. The three most important Runes are:
- $state: Declares a reactive piece of state.
- $derived: Creates a value that is automatically recalculated when its dependencies change.
- $effect: Runs side effects (like DOM manipulation or API calls) when state changes.
1. Deep Dive into $state
In Svelte 4, you would write let count = 0; to create state. In Svelte 5, you write let count = $state(0);. While this seems like more typing, it unlocks fine-grained reactivity.
Consider a complex object like a user profile. In older versions, updating a nested property didn’t always trigger a re-render. With $state, Svelte uses Proxies to track exactly what changed.
<script>
// Svelte 5 approach
let user = $state({
name: 'Alice',
settings: {
theme: 'dark',
notifications: true
}
});
function toggleTheme() {
// This just works! No need for user = user;
user.settings.theme = user.settings.theme === 'dark' ? 'light' : 'dark';
}
</script>
<button on:click={toggleTheme}>
Current theme: {user.settings.theme}
</button>
In the example above, Svelte 5 knows exactly that user.settings.theme changed. It doesn’t need to check the name property or the notifications property. This leads to massive performance gains in large-scale applications.
Universal Reactivity with $state
One of the biggest advantages of $state is that it works outside of .svelte components. You can now create a .svelte.js (or .svelte.ts) file and define reactive logic that can be imported anywhere.
// counter.svelte.js
export function createCounter() {
let count = $state(0);
return {
get count() { return count; },
increment: () => count++,
decrement: () => count--
};
}
By using a getter for count, we ensure that the component consuming this logic always stays in sync with the current value of the state.
2. Mastering $derived
In Svelte 4, we used the $: label for derived values: $: doubled = count * 2;. While succinct, it was sometimes hard to track dependencies, especially in larger components. $derived makes this explicit and more readable.
<script>
let numbers = $state([1, 2, 3]);
// This value updates whenever the 'numbers' array changes
let sum = $derived(numbers.reduce((a, b) => a + b, 0));
// You can even nest derived values
let isLargeSum = $derived(sum > 10);
function addNumber() {
numbers.push(Math.floor(Math.random() * 10));
}
</script>
<p>Numbers: {numbers.join(', ')}</p>
<p>Sum: {sum}</p>
<p>Is it large? {isLargeSum ? 'Yes' : 'No'}</p>
<button on:click={addNumber}>Add Random Number</button>
The beauty of $derived is its “laziness.” If nothing is reading the sum value, Svelte won’t bother calculating it. Furthermore, it avoids the “glitch” problem where intermediate values are rendered incorrectly during a complex state update.
3. Handling Side Effects with $effect
In previous versions, Svelte used lifecycle hooks like onMount, afterUpdate, and the reactive label $: to handle side effects. $effect unifies these. It runs after the component is mounted and whenever the reactive values it depends on change.
<script>
let count = $state(0);
let timer = $state(0);
// This effect runs whenever 'count' changes
$effect(() => {
console.log(`The count is now ${count}`);
// Optional: return a cleanup function
return () => {
console.log('Cleaning up before the next effect run or component unmount');
};
});
// Example of an effect that sets up an interval
$effect(() => {
const interval = setInterval(() => {
timer++;
}, 1000);
// Cleanup: vital for preventing memory leaks
return () => clearInterval(interval);
});
</script>
<p>Count: {count} <button on:click={() => count++}>+</button></p>
<p>Timer: {timer}</p>
Note: You should generally avoid using $effect to sync state. If you can calculate a value based on another value, use $derived instead. Use $effect for things like manual DOM manipulation, fetching data from an API, or integrating with third-party libraries (like D3 or Mapbox).
4. Component Communication with $props
Passing data from parent to child has been redesigned in Svelte 5. Gone is the export let propName; syntax. Instead, we use the $props rune.
<!-- Child.svelte -->
<script>
let { name, age = 18 } = $props();
</script>
<p>Hello {name}, you are {age} years old.</p>
<!-- Parent.svelte -->
<script>
import Child from './Child.svelte';
</script>
<Child name="John" age={25} />
This new syntax is much more aligned with modern JavaScript destructuring. It also makes it easier to use TypeScript for prop validation and allows for easier handling of “rest” props (e.g., let { name, ...rest } = $props();).
Step-by-Step: Building a Reactive “Smart List” with Svelte 5
Let’s put everything we’ve learned into a real-world example. We will build a task manager that filters items in real-time, uses local storage for persistence, and tracks statistics.
Step 1: Define the State
We’ll start by defining our tasks and the search filter. We want this to be reactive so the UI updates as we type.
<script>
let tasks = $state([
{ id: 1, text: 'Learn Svelte 5', done: false },
{ id: 2, text: 'Master Runes', done: false }
]);
let searchTerm = $state('');
</script>
Step 2: Create Derived Values
We need a list of tasks that matches the search term, and we want to know how many tasks are completed.
<script>
// ... existing state
let filteredTasks = $derived(
tasks.filter(t => t.text.toLowerCase().includes(searchTerm.toLowerCase()))
);
let stats = $derived({
total: tasks.length,
completed: tasks.filter(t => t.done).length,
remaining: tasks.filter(t => !t.done).length
});
</script>
Step 3: Implement Persistence with $effect
We want to save our tasks to localStorage every time the list changes.
<script>
// ... existing code
// Load tasks on startup
$effect(() => {
const saved = localStorage.getItem('my-tasks');
if (saved) {
tasks = JSON.parse(saved);
}
});
// Save tasks whenever they change
$effect(() => {
localStorage.setItem('my-tasks', JSON.stringify(tasks));
});
</script>
Step 4: The Final UI Markup
<div class="container">
<h1>Task Manager</h1>
<input
type="text"
bind:value={searchTerm}
placeholder="Filter tasks..."
/>
<ul>
{#each filteredTasks as task}
<li>
<input type="checkbox" bind:checked={task.done} />
<span class:done={task.done}>{task.text}</span>
</li>
{/each}
</ul>
<div class="footer">
Total: {stats.total} | Completed: {stats.completed} | Remaining: {stats.remaining}
</div>
</div>
<style>
.done { text-decoration: line-through; color: gray; }
.container { max-width: 400px; margin: 0 auto; }
input[type="text"] { width: 100%; padding: 8px; margin-bottom: 10px; }
</style>
Common Mistakes and How to Fix Them
1. Overusing $effect
Mistake: Using $effect to update one state variable based on another.
Example: $effect(() => { fullName = firstName + ' ' + lastName; });
Fix: Use $derived. Effects should be for non-Svelte things. Using $derived is more efficient and prevents unnecessary re-renders.
2. Mutating $derived values
Mistake: Trying to change the value of a derived Rune directly.
Example: let double = $derived(count * 2); double = 5;
Fix: Derived values are read-only. If you need to change it, you must change the source state (count) that it depends on.
3. Forgetting the “Getter” Pattern in Shared Logic
Mistake: Returning a raw state variable from a function in a .js file.
Fix: When exporting reactive state from a JavaScript function, return a getter or an object containing the state. This ensures the reactivity connection isn’t “lost” when the variable is accessed in a component.
Comparison: Svelte 4 vs. Svelte 5
| Feature | Svelte 4 (Legacy) | Svelte 5 (Runes) |
|---|---|---|
| State | let count = 0; |
let count = $state(0); |
| Derived | $: doubled = count * 2; |
let doubled = $derived(count * 2); |
| Effects | onMount, afterUpdate, $: {} |
$effect(() => { ... }) |
| Props | export let name; |
let { name } = $props(); |
| Logic Reuse | Svelte Stores (writable/readable) | Universal reactivity with Runes in .js |
Why This Matters for SEO and Performance
From a technical SEO standpoint, the speed of your web application is a vital ranking factor (Core Web Vitals). Svelte 5’s Signal-based approach reduces the work the browser has to do. In Svelte 4, the compiler had to generate code to check various dependencies. In Svelte 5, the browser only updates exactly what changed, nothing more.
This translates to:
- Lower Script Execution Time: Faster “Interaction to Next Paint” (INP).
- Smaller Bundle Sizes: Less boilerplate code for handling complex state.
- Better Memory Management: Signals automatically clean up after themselves when they are no longer needed.
Summary / Key Takeaways
- Runes are the new foundation of Svelte reactivity, providing explicit and powerful control.
- $state replaces basic variable reactivity and handles deep object/array nesting automatically.
- $derived replaces the
$:label for values that depend on other state, offering better performance and reliability. - $effect is the go-to for side effects and lifecycle management, replacing
onMountand reactive statements. - $props provides a modern, destructuring-friendly way to handle component inputs.
- Svelte 5 enables Universal Reactivity, allowing you to use reactive state anywhere, not just inside
.sveltefiles.
Frequently Asked Questions (FAQ)
1. Is Svelte 4 code still compatible with Svelte 5?
Yes! Svelte 5 is designed to be backwards compatible. You can run your existing Svelte 4 components inside a Svelte 5 project. However, to take advantage of the new performance features and fine-grained reactivity, you are encouraged to migrate to Runes gradually.
2. Are Svelte Stores going away?
No, Svelte Stores are not deprecated. They are still useful for certain patterns, especially for global state that needs to be accessed by many unrelated parts of an app. However, for most use cases where you used to use a store, a simple .svelte.js file using $state is now the recommended approach.
3. Do Runes work in standard .js files?
Runes work in .svelte files and in .svelte.js (or .svelte.ts) files. They will not work in standard .js files because the Svelte compiler needs to process them. This naming convention helps the compiler know which files contain reactive logic.
4. How do Runes handle TypeScript?
Runes are designed with TypeScript in mind. $state<T>() and $props() offer excellent type inference and safety, often better than the old export let syntax which was sometimes tricky to type correctly with default values.
5. When should I use $effect instead of $derived?
Use $derived for any value that can be calculated from other state variables (e.g., a filtered list, a total price). Use $effect only when you need to interact with the “outside world,” such as updating the document title, making an HTTP request, or manipulating a canvas element.
