[go: up one dir, main page]

DEV Community

Cover image for Reactivity in Svelte
Ignacio Le Fluk for This Dot

Posted on • Edited on • Originally published at thisdot.co

Reactivity in Svelte

Keeping your application in sync with its state is one of the most important features that a framework can provide. In this post, we'll learn about how reactivity works in Svelte, and avoid common issues when using it.

Let's start a new application to explain how it works.

npm init @vitejs/app

✔ Project name: · svelte-reactivity
✔ Select a framework: · svelte
✔ Select a variant: · svelte-ts

cd svelte-reactivity
pnpm install //use the package manager you prefer
pnpm run dev
Enter fullscreen mode Exit fullscreen mode

We will remove everything we have in our App.svelte component an replace it with the following:

<!-- App.svelte -->
<script lang="ts">
    let language: 'es'|'en' = 'en';

    function toggleLanguage() {
        language = language === 'en' ? 'es' : 'en';
    }
</script>

<main>
    <p>{language}</p>
    <button on:click={toggleLanguage}>Toggle Language</button>
</main>
Enter fullscreen mode Exit fullscreen mode

Reactivity_Svelte_01

We added a button with an event handler responsible for toggling our variable with two values en and es.
We can see that the value is updated every time we click the button.

In Svelte, the DOM is updated when an assignment is made. In this example, language is assigned with the result of language === 'en' ? 'es' : 'en'.
Behinds the scenes, Svelte will take care of rerendering the value of language when the assignment happens.

If we take a look at the compiled code we will find this.

/* App.svelte generated by Svelte v3.38.3 */
// ...

function instance($$self, $$props, $$invalidate) {
    let language = "en";

    function toggleLanguage() {
        $$invalidate(0, language = language === "en" ? "es" : "en");
    }

    return [language, toggleLanguage];
}

// ...
Enter fullscreen mode Exit fullscreen mode

We can see that our toggleLanguage function looks a bit different, wrapping the assignment with the $$invalidate method.

Let's make a few more changes to our file to see how assignment affects reactivity and rerendering.

<!-- App.svelte -->
<script lang="ts">
    let testArray = [0]

    function pushToArray(){
        testArray.push(testArray.length)
    }

    function assignToArray(){
        testArray = [...testArray, testArray.length]
    }
</script>
<main>
    <p>{testArray}</p>
    <button on:click={pushToArray}>Push To Array</button>
    <button on:click={assignToArray}>Assign To Array</button>
</main>
Enter fullscreen mode Exit fullscreen mode

Reactivity_Svelte_02

Reactivity_Svelte_03

Whenever we click on the Assign To Array Button, the DOM is updated with the new value.
When we try to get the same result by mutating the array, the DOM is not updated, but the app state is. We can verify that when we later click the Assignment button and the DOM is updated, showing the actual state of testArray.

Let's inspect the generated code once again.

function instance($$self, $$props, $$invalidate) {
    let testArray = [0];

    function pushToArray() {
        testArray.push(testArray.length);
    }

    function assignToArray() {
        $$invalidate(0, testArray = [...testArray, testArray.length]);
    }

    return [testArray, pushToArray, assignToArray];
}
Enter fullscreen mode Exit fullscreen mode

If you compare both functions, we can now see that only the assignment will call the $$invalidate method, while the other one calls the expression as is.

This doesn't mean we cannot mutate arrays and force a rerender. We need to use an assignment after mutation to do it.

<!-- App.svelte -->
<script lang="ts">
    //...

    function pushToArray(){
        testArray.push(testArray.length)
        testArray = testArray
    }

    //...
</script>
Enter fullscreen mode Exit fullscreen mode

Our complied function will be updated to:

function pushToArray() {
    testArray.push(testArray.length);
    $$invalidate(0, testArray);
}
Enter fullscreen mode Exit fullscreen mode

which will update the DOM when called($$invalidate method wraps the expression, that is simplified to testArray instead of testArray = testArray)

Reactivity_Svelte_04

Reactive Variables

Imagine our team decided that we need to add a second array where each value is squared. If we were doing it imperatively, this would mean we need to update the second array each time the first one changes.
The previous example would look like this.

<!-- App.svelte -->
<script lang="ts">
    let testArray = [0]
    let squared = [0]

    function pushToArray(){
        testArray.push(testArray.length)
        testArray = testArray
        squared = testArray.map(value => value*value)
    }

    function assignToArray(){
        testArray = [...testArray, testArray.length]
        squared = testArray.map(value => value*value)
    }
</script>
<main>
    <p>{testArray}</p>
    <p>{squared}</p>
    <!-- ... -->
</main>
Enter fullscreen mode Exit fullscreen mode

Reactivity_Svelte_05

If we check the generated code again, we'll see that we are invalidating both arrays every time.

function pushToArray() {
    testArray.push(testArray.length);
    $$invalidate(0, testArray);
    $$invalidate(1, squared = testArray.map(value => value * value));
}

function assignToArray() {
    $$invalidate(0, testArray = [...testArray, testArray.length]);
    $$invalidate(1, squared = testArray.map(value => value * value));
}
Enter fullscreen mode Exit fullscreen mode

Unfortunately, this approach has a problem. We need to keep track of every place where testArray is modified, and also update the squared array.

If we think about this problem reactively, we only need to listen to changes in testArray.

In Svelte, there's a special way to do this. Instead of declaring a variable with let, we will use $:. This is a labeled statement (it's valid JS), and it's used by the compiler to let it know that a reactive variable is being declared, and it depends on all the variables that are added to the expression.
In our example:

<script lang="ts">
  let testArray = [0];
  $: squared = testArray.map(value => value * value)

  function pushToArray() {
    testArray.push(testArray.length);
    testArray = testArray;
  }

  function assignToArray() {
    testArray = [...testArray, testArray.length];
  }
</script>
Enter fullscreen mode Exit fullscreen mode

Using this reactive approach, we need to handle changes to testArray exclusively.
The compiler will detect that there's a dependency in testArray to calculate the actual value of squared.

If you run the app again, the same behavior is achieved.

How did this happen?
Let's look at our compiled code.

    $$self.$$.update = () => {
        if ($$self.$$.dirty & /*testArray*/ 1) {
            $: $$invalidate(1, squared = testArray.map(value => value * value));
        }
    };
Enter fullscreen mode Exit fullscreen mode

The internal property update is now assigned to a function that will check if the instance has changed, and that invalidate squared if the condition is met.

Every other reactive variable we add to our component will add a new block that will check if a dependency changed, and invalidate the declared variable.
For example:

<script lang="ts">
  let testArray = [0];
  let multiplier = 5
  $: squared = testArray.map(value => value * value)
    // if ($$self.$$.dirty & /*testArray*/ 1) {
    //   $: $$invalidate(1, squared = testArray.map(value => value * value));
    // }
  $: squaredTwice = squared.map(value => value * value)
    // if ($$self.$$.dirty & /*squared*/ 2) {
    //   $: squaredTwice = squared.map(value => value * value);
    // }
  $: multiplied: squaredTwice.map(value => value * multiplier)
    // if ($$self.$$.dirty & /*squaredTwice, multiplier*/ 34) {
    //   $: multiplied = squaredTwice.map(value => value * multiplier);
    // }

</script>
<!-- ... -->
Enter fullscreen mode Exit fullscreen mode

The last declaration, however, depends on two variables, squareTwice and multiplier. You can tell by the comment in the if condition.

Our updated component now looks like this:

<script lang="ts">
  let testArray = [0];
  let multiplier = 5;

  $: squared = testArray.map((value) => value * value);
  $: squaredTwice = squared.map((value) => value * value);
  $: multiplied = squaredTwice.map((value) => value * multiplier);

  function pushToArray() {
    testArray.push(testArray.length);
    testArray = testArray;
  }

  function assignToArray() {
    testArray = [...testArray, testArray.length];
  }
</script>

<main>
  <p>{testArray}</p>
  <p>{squared}</p>
  <p>{squaredTwice}</p>
  <p>{multiplied}</p>
  <button on:click={pushToArray}>Push To Array</button>
  <button on:click={assignToArray}>Assign To Array</button>
  <button on:click={() => multiplier = multiplier + 1}>Multiplier</button>
</main>
Enter fullscreen mode Exit fullscreen mode

I added a button to add 1 to multiplier to verify that the multiplied array is also depending on it.

Reactivity_Svelte_06

Reactive Statements

Reactivity is not limited to variable declarations. Using the same $: pattern we can create reactive statements.
For example, we could add an if statement or add a try-catch block.

Let's try the following:

<script lang="ts">
  //...
  let error = null;
  //...
  $: try {
    if (multiplier > 8) {
      throw 'boo';
    }
  } catch (e) {
    error = e;
  }
  //...
</script>

<main>
  <!-- ... -->
  {#if error}
    <p>{error}</p>
  {/if}
  <!-- ... -->
</main>
Enter fullscreen mode Exit fullscreen mode

Looking at the generated code we can see the same pattern as before:

if ($$self.$$.dirty & /*multiplier*/ 2) {
    $: try {
        if (multiplier > 8) {
            throw "boo";
        }
    } catch(e) {
        $$invalidate(4, error = e);
    }
}
Enter fullscreen mode Exit fullscreen mode

The compiler recognizes how the statement depends on changes to multiplier and that invalidating error is a possibility.

Store auto-subscription

A store is defined as an object that implements the following contract (at a minimum):
store = { subscribe: (subscription: (value: any) => void) => (() => void), set?: (value: any) => void }
Stores are beyond the scope of this post but they will make possible to listen for changes to a piece of your app state.
Then, we can translate this event (when the store emits a new value) into an assignment that, as we mentioned before, will update our DOM.
For example:

// stores.ts
import { writable } from 'svelte/store';
export const storeArray = writable([0]);
Enter fullscreen mode Exit fullscreen mode
<!-- App.svelte -->
<script lang="ts">
  import { onDestroy } from 'svelte';
  import { storeArray } from './stores';

  let testArray;
  const unsubscribe = storeArray.subscribe((value) => {
    testArray = value;
  });
  function addValueToArray() {
    storeArray.update((value) => [...value, value.length]);
  }
  onDestroy(unsubscribe);
</script>

<main>
  <p>{testArray}</p>
  <button on:click={addValueToArray}>Add Value</button>
</main>

Enter fullscreen mode Exit fullscreen mode

Whenever we update, or set our store, a new value will be emitted and assigned to testArray.

We can confirm that we are calling $$invalidate in the compiled code.

const unsubscribe = storeArray.subscribe(value => {
        $$invalidate(0, testArray = value);
    });
Enter fullscreen mode Exit fullscreen mode

Reactivity_Svelte_08

But there's another way to achieve this with auto-subscriptions.

Our component now becomes this:

<script lang="ts">
  import { storeArray } from './stores';
  function addValueToArray() {
    storeArray.update((value) => [...value, value.length]);
  }
</script>

<main>
  <p>{$storeArray}</p>
  <button on:click={addValueToArray}>Add Value</button>
</main>
Enter fullscreen mode Exit fullscreen mode

Looking at auto subscriptions. There's no assignment in it, but our DOM is updated when we update the array. How is this achieved?

Let's analyze the output code:

function instance($$self, $$props, $$invalidate) {
    let $storeArray;
    component_subscribe($$self, storeArray, $$value => $$invalidate(0, $storeArray = $$value));

    function addValueToArray() {
        storeArray.update(value => [...value, value.length]);
    }

    return [$storeArray, addValueToArray];
}
Enter fullscreen mode Exit fullscreen mode

We can see that we are calling component_subscribe with three parameters: the component, the store, and a callback function, which is invalidating our $storeArray variable.

If we go deeper and check what component_subscribe is doing underneath, we'll find the following:

export function subscribe(store, ...callbacks) {
    if (store == null) {
        return noop;
    }
    const unsub = store.subscribe(...callbacks);
    return unsub.unsubscribe ? () => unsub.unsubscribe() : unsub;
}

export function component_subscribe(component, store, callback) {
    component.$$.on_destroy.push(subscribe(store, callback));
}
Enter fullscreen mode Exit fullscreen mode

... Which is doing the same as the original code.

It subscribes to the store, and returns a unsubscribe method (or an object with an unsubscribe method), and calls it when the component is destroyed. When a new value is emitted, the callback is executed ($$invalidate), assigning the emitted value to the auto-subscribe variable.

Common issues

  • Remember that we need an assignment to call $$invalidate mark the component instance as dirty and run all the checks.
    =, ++, --, +=, -= are all considered assignments.

  • When working with objects, the assignment must include the name of the variable referenced in the template.
    For example:

<script>
  let foo = { bar: { baz: 1 } };
  let foo2 = foo;
  function addOne() {
    foo2.bar.baz++;
  }
  function refreshFoo() {
    foo = foo;
  }
</script>
<p>foo: {JSON.stringify(foo, null, 2)}</p>
<p>foo2: {JSON.stringify(foo2, null, 2)}</p>

<button on:click={addOne}> add 1 </button>

<button on:click={refreshFoo}> Refresh foo </button>
Enter fullscreen mode Exit fullscreen mode

When adding 1 to foo2.bar.baz the compiler only knows that it must update references to foo2 in the templates, but it will not update references to foo event if it changes too (they're the same object). When calling refreshFoo we are manually invalidating foo
Reactivity_Svelte_07

  • when mutating arrays, be mindful of adding an assignment at the end to let the compiler know that it must update the template references.

Wrapping up

In general, whenever an assignment is made it will compile into an $$invalidate method that will mark the component as dirty and apply the required changes to the DOM.
If there's any reactive variable (or statement) it will check if the component is marked as dirty and if any of its dependencies changed (because it has been invalidated), if that's the case then it will also invalidate it.
Store auto subscription creates an assignment that invalidates the $ prepended variable when the store emits a new value.

This Dot Labs is a modern web consultancy focused on helping companies realize their digital transformation efforts. For expert architectural guidance, training, or consulting in React, Angular, Vue, Web Components, GraphQL, Node, Bazel, or Polymer, visit thisdotlabs.com.

This Dot Media is focused on creating an inclusive and educational web for all. We keep you up to date with advancements in the modern web through events, podcasts, and free content. To learn, visit thisdot.co.

Top comments (0)