Learning the new Svelte v5 Reactivity System

So Svelte v5, the newest rendition of what probably is the best front-end framework in existence, has been released and it is vastly different from its previous version. The main difference lies at its core: How reactivity of variables is implemented. Because of these changes, Svelte became easier, and at the same time a bit more difficult.

Since I have been working intensely with Svelte v5 since v5@next.155 in real-world micro-frontend projects, I decided to write this series of articles to pass on my gained knowledge to help you understand, embrace and potentially migrate your code to Svelte v5.

A Brief Introduction: Svelte v4 Reactivity

Svelte v4’s reactivity system is nothing short of a work of art: Svelte statically analyzes code in components and then generates code that imperatively changes the DOM whenever regular JavaScript variables change. Simple, elegant, and very performant. A quick example:

<script lang="ts">
    let clickCount = 0;

    function countClicks() {
        ++clickCount;
    }
</script>

<svelte:document on:click={() => countClicks()} />
<pre>Clicks inside document: {clickCount}</pre>

This simple component adds a “click” event listener to the document object and counts clicks. The click count is displayed in real time with just the above code. Amazing, right?

Like everything in life, though, this is not perfect. Rich Harris (the creator of Svelte) has explained the caveats, and I won’t go through those in this article. I’ll just mention one: Code refactor.

Code Refactoring is not Possible

One of the more important caveats is the inability to carry this reactivity system outside the component. One cannot, for instance, create a reusable module that encapsulates the implementation of the countClicks function in the example.

Because reactivity depends on static analysis, taking the function away and into a module will hide the variable mutation to the static code analyzer and then reactivity is lost.

Reactivity in Svelte v5: Runes

The term rune refers to a “magic symbol”, and is the term that Svelte adopted to name the following function-looking “magic” terms:

  • $state

  • $props

  • $bindable

  • $derived

  • $effect

Reactivity in Svelte v5 is governed by the use of these runes.

It is important to note that while they look like R-values, their produced code is really L-values. In other words, don’t think that you can pass around state from variable to variable. This is detailed a bit more below in the $state rune section.

The main advantages of this new reactivity system are:

  • Ability to refactor reactive code outside of components

  • Fine-grained reactivity

The former means that we can have reactive variables outside components; the latter means that component re-renders are more targeted when it comes to reacting on changing state.

The ability to have state outside components is not covered in this article but follow the series because an article about this will come.

Fine-grained reactivity, on the other hand, means that Svelte can now know which property changed in a state object, and only re-renders (and re-runs effects and recalculates derived values) what is affected by that specific property only. This is in some cases a major performance improvement. As a quick example: If a large table component sees a new row added to its data, Svelte will only render the new row. If a single cell value in the 3rd row changes, only the cell that shows the value will be re-rendered. Is it clear now? Hopefully it is, but if not, hit me in the Comments section.

The $state Rune

This rune is used to create reactive state. Let’s re-write the Svelte v4 code sample from above:

<script lang="ts">
    let clickCount = $state(0);

    function countClicks() {
        ++clickCount;
    }
</script>

<svelte:document onclick={() => countClicks()} />
<pre>Clicks inside document: {clickCount}</pre>

To achieve the same result that we did in Svelte v4, we simply used $state(0) instead of 0.

The main rule that governs this rune is that it can only be used to initialize variables or class fields, and it has to do with the important note you read a minute ago: Runes look like functions syntactically, but they are not. The compiler replaces runes with code that is not compatible with the idea of what a function does, which is calculate and return a value. This means that the following doesn’t create a second reactive variable:

<script lang="ts">
    let clickCount = $state(0);
    let secondClickCount = clickCount;

    function countClicks() {
        ++clickCount;
    }
</script>

The reactive nature of clickCount is not transferred or copied over to secondClickCount by virtue of using the assignment operator. If runes were functions, the above would have worked, but they are not.

There is one more important thing to mention about $state: It makes its value deeply reactive. This means that if the value is an object whose properties contain objects, then the properties of the contained objects are also reactive. This pattern applies recursively, so the entire object graph is ultimately reactive.

The $props Rune

Component properties are expected to be reactive, and Svelte v5 achieves this using the $props rune.

<script lang="ts">
    type Props = {
        data: number[];
        operation?: 'sum', 'avg';
    };

    let {
        data,
        operation = 'sum',
        ...restProps,
    }: Props = $props();

    function sum() {
        return data.reduce((p, c) => p + c);
    }

    function avg() {
        return sum() / data.length
    }
</script>

<span class="amount" {...restProps}>{operation === 'sum' ? sum() : avg()}</span>

<style>
    .amount {
        font-family: monospace;
    }
</style>

Because using TypeScript is everything to a project, we start by declaring the component properties using a type. Optional properties are marked by appending ? to its name.

Then comes the use of the rune, which is a de-structuring statement. The example shows how to assign default values and how to allow “rest” properties, which is any other property. The component spreads (applies) this “rest” of properties as properties (attributes) on the span HTML element.

You could do let props: Props = $props(); to define the properties and it works, but then you cannot specify defaults for the various properties, so I suggest you always declare properties as shown. I also wouldn’t know how to declare restProperties either if not de-structuring, by the way.

If you have been paying attention, the above produces a TypeScript error. After all, the Props type has no mention about any “rest” property. How can we type restProps?

Generally speaking, you could do stuff like the following to allow all kinds of stuff. It is up to your TypeScript skills, I suppose.

The following opens the Props type up to allow any data-* attribute:

<script lang="ts">
    type Props = {
        data: number[];
        operation?: 'sum', 'avg';
        [x: `data-${string}`]: any;
    };
</script>

This one allows anything:

<script lang="ts">
    type Props = {
        data: number[];
        operation?: 'sum', 'avg';
        [x: string]: any;
    };
</script>

But more often than not, one would need to allow the attributes of the HTML element that receives restProps, and in our example, that was the span HTML element.

For this common scenario, Svelte v5 provides types that should cover most of the HTML elements:

<script lang="ts">
    import type { HTMLAttributes } from 'svelte/elements';

    type Props = {
        data: number[];
        operation?: 'sum', 'avg';
    } & HTMLAttributes<HTMLSpanElement>;
</script>

Using the latter will make GUI’s like VS Code provide accurate Intellisense on the possible props (attributes) for the span HTML element. Nice, right?

The HTMLAttributes<T> interface is used for HTML elements that have no particularities in their list of properties. Many elements, however, do have. For example, rather than doing HTMLAttributes<HTMLButtonElement>, import the HTMLButtonAttributes interface from 'svelte/elements'.

The last detail is default values. There’s really not much to say and the example says it all: The default value of the operation prop is 'sum'. If the property is not specified when the component is used, that’s the value the prop will assume.

If the wanted default is undefined, then don’t specify anything at all.

The $bindable Rune

This one is a very specific rune that can only be used in component properties. It marks a property as bindable.

If you don’t know or don’t recall, Svelte allows for 2-way binding of properties. Vue also has this feature, and in contrast, React does not.

Usage is super simple:

// This is Rating.svelte
<script lang="ts">
    type Props = {
        value?: number;
    };

    let {
        value = $bindable(5),
    }: Props = $props();
</script>

<span>Rate Us:</span>
<input type="range" min="1" max="5" bind:value />

// This is a component consuming Rating.svelte
<script lang="ts">
    import Rating from './Rating.svelte';
    let rating = $state(3);
</script>

<Rating bind:value={rating} />

Always make properties that get their values modified as bindable, or Svelte will complain with a console warning. The warning states that components should not modify state that doesn’t belong to them, and that if this is intended, then a binding should be used.

As shown in the example, one can specify a property default through the $bindable rune. The example sets the property’s default to 5.

But does a default even make sense here? Well, yes. Declaring a property as bindable doesn’t make it required.

The $derived Rune

Whenever we need to calculate a value using values from props or other reactive state (that can change over time), we use the $derived rune.

Bringing the example component that calculates sums and averages back, we can re-write it using this rune:

<script lang="ts">
    import type { HTMLAttributes } from 'svelte/elements';

    type Props = {
        data: number[];
        operation?: 'sum', 'avg';
    } & HTMLAttributes<HTMLSpanElement>;

    let {
        data,
        operation = 'sum',
        ...restProps,
    }: Props = $props();

    let result = $derived(operation === 'sum' ? sum() : avg());

    function sum() {
        return data.reduce((p, c) => p + c);
    }

    function avg() {
        return sum() / data.length
    }
</script>

<span class="amount" {...restProps}>{result}</span>

<style>
    .amount {
        font-family: monospace;
    }
</style>

Now we have a new variable named result that is as reactive as its input and will automatically recalculate every time data in the data array changes. It itself is a reactive variable, so the template (the HTML part of the component) that uses it will also update.

The $effect Rune

This rune allows us to specify arbitrary code that runs whenever reactive data changes. For this rune to work its magic, it keeps track of the reactive data that is read during its execution. This inventory of reactive data is then used to re-trigger the effect whenever anything in the inventory changes its value.

Probably the most common scenario is to re-trigger a data fetch operation based on changing values:

<script lang="ts">
    import type { MyData } from './my-types.js';

    type Props = {
        pageSize?: number;
    };

    let {
        pageSize = 20,
    }: Props = $props();

    let data = $state<MyData[] | null>(null);

    $effect(() => {
        fetchMyData();
    });

    async function fetchMyData() {
        const response = await fetch(`/api/my-data?pageSize=${pageSize}`, { ... });
        if (response.ok) {
            data = JSON.parse(await response.json());
        }
        data = null;
    }
</script>

<ul>
    {#each data as item (item.id)}
        <li>
            <!-- Render data here -->
        </li>
    {/each}
<ul>

Asynchronous operations are usually the norm inside effects whenever we don’t want our $derived variables to hold promises. Personally, though and because it is so easy to work with promises in Svelte, I would simply use a $derived value. The variant shown next makes data a reactive calculated value that holds a promise:

<script lang="ts">
    import type { MyData } from './my-types.js';

    type Props = {
        pageSize?: number;
    };

    let {
        pageSize = 20,
    }: Props = $props();

    let data = $derived(fetchMyData());

    async function fetchMyData() {
        const response = await fetch(`/api/my-data?pageSize=${pageSize}`, { ... });
        if (response.ok) {
            return JSON.parse(await response.json()) as MyData[];
        }
        return null;
    }
</script>

{#await data}
    <Spinner>Loading data...</Spinner>
{:then items}
    <ul>
        {#each items as item (item.id)}
            <li>
                <!-- Render data here -->
            </li>
        {/each}
    <ul>
{:catch reason}
    <span>Oops! - {reason}</span>
{/await}

Generally speaking, if you are doing a combination of $state and $effect, you’re most likely better off with a $derived. There are exceptions to this rule, though, so take it as a rule of thumb and not the Holy Word.

If data fetching is not a good example for $effect, then what is? Let’s see this one:

<script lang="ts">
    type Props = {
        elapsed?: number;
        status?: 'not-started' | 'running' | 'paused ' | 'stopped';
    };

    let {
        elapsed = $bindable(0),
        status = 'not-started',
    }: Props = $props();

    let accumElapsed = $state(0);
    let startTime: Date;
    let intervalId: NodeJS.Timeout;
    let stopped = false;

    $effect(() => {
        switch (status) {
            case 'not-started':
                clearInterval(intervalId);
                elapsed = 0;
                accumElapsed = 0;
                stopped = false;
                break;
            case 'running':
                startTime = new Date();
                if (stopped) {
                    stopped = false;
                    elapsed = 0;
                    accumElapsed = 0;
                }
                runInterval();
                break;
            case 'paused':
                stopped = false;
                clearInterval(intervalId);
                accumElapsed += elapsed;
                break;
            case 'stopped':
                stopped = true;
                clearInterval(intervalId);
                break;
        }
    });

    function runInterval() {
        intervalId = setInterval(() => {
            elapsed = Math.floor((new Date().getTime() - startTime.getTime()) / 1_000) + accumElapsed;
        }, 1_000);
    }
</script>

<div>
    <span>Elapsed time: <span>{elapsed}</span> seconds</span>
</div>

This is a simple timer component that is controlled via its status property. The $effect rune is used here to enforce the timer’s operation. Can you imagine refactoring this to $derived? By the way, don’t try because elapsed is a prop, so it cannot be $derived and prop at the same time.

Conclusion

Svelte v5 comes with a brand-new reactivity engine that is geared towards better performance in re-renders and better code refactoring. Using the new reactivity system is both simple and complex: Simple because the common scenarios are well covered by the system’s design, and a bit more difficult because, when compared to v4, the code has become a bit more complex.

Regardless, the new system is powerful and caters for most scenarios gracefully and effectively, providing runes for all possibilities that are straightforward to use, albeit a bit odd at first.

What’s Next

This article has only covered the introductory part of runes, plus some bits of personal experience using them. There are more topics to cover to help you, fellow reader, ramp up more quickly with this new rendition of Svelte, namely:

  • In-depth knowledge of how $effect works

  • Advanced runes ($state.raw, $derived.by, $effect.pre, etc.)

  • Replacing stores with reactive state

  • Out-of-the-norm scenarios

Bonus: Performance Charts

Have a look at these benchmark results: Interactive Results (krausest.github.io)

Now, the list of frameworks is appalling, so you can copy the following JSON, and then paste it into the web page using the Paste button (see screenshot):

{"frameworks":["keyed/angular-cf","keyed/react-classes","keyed/react-compiler-hooks","keyed/react-redux","keyed/react-redux-hooks","keyed/react-redux-hooks-immutable","keyed/solid","keyed/solid-store","keyed/svelte","keyed/svelte-classic","keyed/vanillajs","keyed/vue","keyed/vue-jsx","keyed/vue-pinia","non-keyed/react-classes","non-keyed/svelte-classic","non-keyed/vanillajs","non-keyed/vue"],"benchmarks":["01_run1k","02_replace1k","03_update10th1k_x16","04_select1k","05_swap1k","06_remove-one-1k","07_create10k","08_create1k-after1k_x2","09_clear1k_x8","21_ready-memory","22_run-memory","23_update5-memory","25_run-clear-memory","26_run-10k-memory","41_size-uncompressed","42_size-compressed","43_first-paint"],"displayMode":1}

By the way, I think that simply focusing the window and pasting via keyboard works too.

This narrows down the list of frameworks to the more popular ones, or at least what I consider popular. Maybe you know better than me.

It is a shame that Svelte v4 is no longer available in the charts, but as you can see, out of the selected frameworks, the top 3 is undisputable: Vanilla JS, Solid and Svelte.

On the other end of the spectrum, it is just sad to see React v19 performing so poorly. Wasn’t the compiler supposed to make it like a lot better? It seems that it ended up being wasted effort. Sure, it seems to outperform React v18, but that’s about it. It is uncertain to me why Meta continues to invest money in React. Thoughts, anyone?

0
Subscribe to my newsletter

Read articles from José Pablo Ramírez Vargas directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

José Pablo Ramírez Vargas
José Pablo Ramírez Vargas