Svelte 5 Runes: A Developer's Guide to the Future of Reactivity

The world of JavaScript frameworks is in a constant state of evolution, with each new paradigm promising to solve the complexities of building for the modern web. Svelte carved its niche by turning the paradigm on its head—moving reactivity from the runtime into the compiler. This resulted in highly performant applications with remarkably little boilerplate. Now, Svelte 5 is here, and it's introducing a change that is both a natural evolution and a significant philosophical shift: Runes.

Runes are Svelte's answer to a more explicit, predictable, and powerful state management system. They move away from the 'magical' behind-the-scenes compilation that made Svelte 4 so unique, where a simple let declaration could become a reactive statement. While this magic was a source of delight, it also had its limitations. Runes replace this implicit behavior with explicit, function-like signals that are both instantly understandable and incredibly versatile.

This article is your comprehensive guide to Svelte 5 Runes. We'll explore why this change was necessary, break down the core primitives—$state, $derived, and $effect—and show you how to start using them effectively to build more robust and maintainable Svelte applications.

The 'Why': Unpacking the Limitations of Svelte's Old Reactivity

To fully appreciate the power of Runes, it's essential to understand the problems they were designed to solve. Svelte's original compiler-based reactivity was a masterclass in developer experience for many use cases, but as applications grew in complexity, certain cracks began to show.

The Magic of `let` and `$: `

In Svelte 4 and earlier, reactivity felt like an extension of plain JavaScript. Any variable declared with `let` at the top level of a component's `<script>` block was automatically treated as reactive state. Any assignment to that variable would trigger a DOM update.

For derived state and side effects, Svelte provided the `$: ` label, also known as the 'reactive declaration'.

<script>\n  let count = 0;\n\n  // This is a derived value. It re-runs whenever 'count' changes.\n  $: double = count * 2;\n\n  // This is a side effect. It also re-runs when 'count' changes.\n  $: console.log(`The count is ${count}`);\n</script>

For simple components, this was brilliant. It required minimal boilerplate and felt incredibly intuitive. However, this simplicity was powered by a complex compiler that instrumented your code behind the scenes. This 'magic' could sometimes become opaque, leading to confusion about what was reactive and why. For example, mutating an object or array without a direct assignment (`myArray.push(newItem)`) wouldn't trigger an update, forcing developers to use idiomatic but less direct patterns like `myArray = [...myArray, newItem]`.

Challenges with Scalability and Predictability

The compiler-driven approach hit its limits in larger, more complex applications. The most significant pain point was that reactivity was fundamentally tied to the context of a `.svelte` file. You couldn't just create a reactive primitive in a plain `.js` or `.ts` file and import it. The workaround was Svelte's store contract (`readable`, `writable`, `derived`), which, while powerful, introduced a separate API and syntax (`$storeName`) that felt disconnected from component state.

This created a cognitive divide: component state worked one way, and shared state worked another. Developers had to constantly track what was reactive versus what was plain JavaScript, a mental overhead that could lead to subtle bugs. The rules governing when a `$: ` block would re-run—only when one of its referenced variables was *assigned* to—were not always obvious, further complicating the mental model for large-scale state management.

Meet the Runes: Core Primitives of Svelte 5

Runes replace the compiler's implicit magic with explicit, function-based primitives. These are inspired by the concept of signals, a pattern gaining traction across frontend frameworks for its fine-grained and predictable nature. Let's meet the core trio.

`$state`: The Foundation of Reactive State

`$state` is the new cornerstone of reactivity in Svelte. It's a function that you call to create a piece of reactive state. It replaces the top-level `let` declaration for any variable that needs to trigger updates when it changes.

The key benefit is explicitness. When you see `$state`, you know you're dealing with a reactive value. Furthermore, this reactivity is no longer confined to the component scope; it can be used anywhere in your JavaScript/TypeScript code.

Svelte 4:

<script>\n  let count = 0;\n\n  function increment() {\n    count += 1;\n  }\n</script>

Svelte 5 with Runes:

<script>\n  let count = $state(0);\n\n  function increment() {\n    count += 1;\n  }\n</script>

The syntax is simple, but the implication is huge. Reactivity is now explicitly declared, not inferred.

`$derived`: Computing Values from State

`$derived` is the Rune for creating values that are calculated from other reactive state. It is the direct and explicit replacement for the `$: ` label when used for computations.

A crucial feature of `$derived` is that it creates a memoized computation. The expression inside `$derived` will only be re-evaluated if its dependencies—the `$state` or other `$derived` values it reads—have changed. This ensures optimal performance by preventing unnecessary recalculations.

Svelte 4:

<script>\n  let count = 0;\n  $: double = count * 2;\n</script>

Svelte 5 with Runes:

<script>\n  let count = $state(0);\n  let double = $derived(count * 2);\n</script>

This code is not just syntactically different; it's semantically clearer. It reads as '`double` is a value derived from `count`'.

`$effect`: Running Side Effects in Response to Changes

`$effect` is the Rune for running code that needs to react to state changes but doesn't produce a new value. This is for side effects: interacting with the browser APIs (like `localStorage`), logging to the console, fetching data, or setting up subscriptions.

It replaces the `$: ` label when used to call a function or run a block of code. The code inside an `$effect` will re-run automatically whenever any reactive values it depends on are updated. It runs after the DOM has been updated with the latest changes.

Svelte 4:

<script>\n  let count = 0;\n\n  $: {\n    console.log(`The count has changed to: ${count}`);\n    document.title = `Count is ${count}`\n  }\n</script>

Svelte 5 with Runes:

<script>\n  let count = $state(0);\n\n  $effect(() => {\n    console.log(`The count has changed to: ${count}`);\n    document.title = `Count is ${count}`\n  });\n</script>

`$effect` provides a clean and predictable lifecycle for side effects, making it clear which code interacts with the world outside of the component's render logic.

Putting It All Together: A Practical Refactoring Example

Theory is great, but seeing Runes in action is where their value truly clicks. Let's refactor a classic to-do list component from Svelte 4 to the new Svelte 5 syntax to see how these primitives work in concert.

Before: A To-Do List in Svelte 4

Here is a standard, functional to-do list component built with Svelte 4's reactivity model. It uses `let` for state and `$: ` to compute the count of remaining items.

<!-- TodoList.svelte (Svelte 4) -->\n<script>\n  let todos = [\n    { id: 1, text: 'Learn Svelte', done: true },\n    { id: 2, text: 'Build an app', done: false }\n  ];\n  let newTodoText = '';\n\n  $: remaining = todos.filter(t => !t.done).length;\n\n  function addTodo() {\n    if (!newTodoText.trim()) return;\n    todos = [...todos, { id: Date.now(), text: newTodoText, done: false }];\n    newTodoText = '';\n  }\n\n  function toggleDone(id) {\n    const todo = todos.find(t => t.id === id);\n    if (todo) {\n      todo.done = !todo.done;\n      todos = todos; // Assignment required to trigger update\n    }\n  }\n</script>\n\n<main>\n  <h1>To-Do List</h1>\n  <h2>{remaining} remaining</h2>\n\n  <form on:submit|preventDefault={addTodo}>\n    <input bind:value={newTodoText} placeholder=\"What needs to be done?\" />\n    <button type=\"submit\">Add</button>\n  </form>\n\n  <ul>\n    {#each todos as todo (todo.id)}\n      <li>\n        <input type=\"checkbox\" bind:checked={todo.done} on:change={() => toggleDone(todo.id)} />\n        <span style:text-decoration={todo.done ? 'line-through' : 'none'}>\n          {todo.text}\n        </span>\n      </li>\n    {/each}\n  </ul>\n</main>

After: The Same To-Do List with Runes

Now, let's refactor the same component to use Runes. Notice how `$state` makes the state explicit, `$derived` clarifies the computation, and the event handlers can now mutate state directly without needing reassignment tricks.

<!-- TodoList.svelte (Svelte 5) -->\n<script>\n  let todos = $state([\n    { id: 1, text: 'Learn Svelte 5', done: true },\n    { id: 2, text: 'Build an app with Runes', done: false }\n  ]);\n  let newTodoText = $state('');\n\n  let remaining = $derived(todos.filter(t => !t.done).length);\n\n  function addTodo() {\n    if (!newTodoText.trim()) return;\n    // We can now just push! No reassignment needed.\n    todos.push({ id: Date.now(), text: newTodoText, done: false });\n    newTodoText = '';\n  }\n  \n  // We can even use an effect to persist to localStorage\n  $effect(() => {\n    if (typeof window !== 'undefined') {\n      localStorage.setItem('svelte5-todos', JSON.stringify(todos));\n      console.log('Todos saved!');\n    }\n  });\n\n</script>\n\n<main>\n  <h1>To-Do List</h1>\n  <!-- Note: derived values are read-only, so we use its value directly -->\n  <h2>{remaining} remaining</h2>\n\n  <form on:submit|preventDefault={addTodo}>\n    <!-- We bind directly to the stateful value -->\n    <input bind:value={newTodoText} placeholder=\"What needs to be done?\" />\n    <button type=\"submit\">Add</button>\n  </form>\n\n  <ul>\n    {#each todos as todo (todo.id)}\n      <li>\n        <!-- Direct mutation on change works fine -->\n        <input type=\"checkbox\" bind:checked={todo.done} />\n        <span style:text-decoration={todo.done ? 'line-through' : 'none'}>\n          {todo.text}\n        </span>\n      </li>\n    {/each}\n  </ul>\n</main>

The result is code that is arguably more readable and less prone to reactivity gotchas.

Bonus: Building a Reusable Store with Runes

Perhaps the most powerful feature of Runes is that they liberate reactivity from `.svelte` files. We can now create complex, reactive logic in plain TypeScript or JavaScript files and import them anywhere. This is a massive win for code organization and reusability.

Let's create a simple, reusable counter store.

`counter.store.js`

import { $state, $derived } from 'svelte/js';\n\nexport function createCounter(initialCount = 0) {\n  // Create reactive state inside a regular function\n  let count = $state(initialCount);\n  let double = $derived(count * 2);\n\n  function increment() {\n    count++;\n  }\n\n  function decrement() {\n    count--;\n  }\n\n  // Expose the reactive values and methods\n  return {\n    get count() { return count; },\n    get double() { return double; },\n    increment,\n    decrement\n  };\n}

`CounterComponent.svelte`

<script>\n  import { createCounter } from './counter.store.js';\n\n  // Instantiate our store\n  const counter = createCounter(5);\n</script>\n\n<div>\n  <p>Current count: {counter.count}</p>\n  <p>Double the count: {counter.double}</p>\n  <button on:click={counter.increment}>+</button>\n  <button on:click={counter.decrement}>-</button>\n</div>

This pattern of creating composable, reactive primitives is a true game-changer for Svelte architecture, enabling cleaner separation of concerns and more scalable state management solutions.

The Bigger Picture: What Runes Mean for the Future of Svelte

The introduction of Runes is more than a syntax update; it's a strategic move that positions Svelte for the future, addressing long-standing challenges and opening up new possibilities.

Improved Developer Experience and Tooling

Explicit signals make code significantly easier to reason about. There's no more ambiguity about which variables are reactive. This clarity simplifies debugging, as the flow of data is more transparent. This explicitness is also a boon for tooling. Static analysis tools, linters, and IDEs can more reliably understand Svelte code without needing deep integration with the compiler's inner workings, leading to better autocompletion, type-checking with TypeScript, and error detection.

Performance and Granularity

While Svelte has always been known for performance, Runes unlock a new level of optimization. The old reactivity model was largely component-scoped; a change could cause all reactive declarations in a component to be re-evaluated. Runes enable truly fine-grained reactivity. When a `$state` value changes, only the specific `$derived` values and `$effect`s that depend on it are updated. This surgical precision means Svelte can do even less work to update the DOM, resulting in faster and more efficient applications, especially in data-dense interfaces.

Lowering the Barrier to Entry

This might seem counter-intuitive, as Runes add new concepts to learn. However, by adopting a function-based, signal-like model, Svelte aligns more closely with patterns seen in other modern frameworks. A developer coming from React will find `$state`, `$derived`, and `$effect` conceptually similar to `useState`, `useMemo`, and `useEffect`. This shared mental model can actually make it easier for developers from other ecosystems to become productive in Svelte, reducing the framework's 'uniqueness' barrier and broadening its appeal.

Embracing the Future: Your Next Steps with Svelte 5

Svelte 5's Runes represent a significant and exciting evolution for the framework. By trading compiler magic for explicit, function-based primitives like `$state`, `$derived`, and `$effect`, Svelte is empowering developers to write more scalable, predictable, and maintainable code. This shift doesn't abandon Svelte's core philosophy of simplicity and performance; it enhances it, providing a more robust foundation for the next generation of web applications.

This is a positive and necessary step forward, one that addresses the growing pains of complex applications while opening up powerful new patterns for state management. The future of Svelte is explicit, granular, and more powerful than ever.

Your journey with Svelte 5 starts now. We encourage you to dive in:

  • Explore the Svelte 5 Playground: Get your hands dirty and experiment with Runes in a live environment without any setup.
  • Start a side project: The best way to learn is by building. Try refactoring a small existing project or starting a new one with Runes.
  • Consult the official documentation: As Runes become stable, the official docs will be the ultimate source of truth for advanced use cases and other available Runes.

Welcome to the next chapter of Svelte.

Happy coding,
— The ToolShelf Team