Table of Contents

Objects in Svelte Stores: Thoughts and Best Practices

Svelte stores are perfect for handling nested objects, but there are a couple of things you want to be aware of.

17 Dec 2022. 1690 words.


Introduction

Svelte stores are an amazing tool to make writing an app in Svelte more exciting. You just need one line of code with $ and bind: to create an input connected to a global state:

<script>
  import { writable } from "svelte/store";
  const name = writable("John");
</script>

<label>
  What is your name?
  <input type="text" bind:value={$name} />
</label>
<div>
  Hello, {$name}!
</div>

Storing an object is also fairly straightforward because we can use $store.key notation. Have a look at the example below.

import { writable } from "svelte/store";

export const store = writable({
  name: "John",
  age: 18,
});
<script>
  import { store } from "./stores";
</script>

<label>
  What is your name?
  <input type="text" bind:value={$store.name} />
</label>
<div>
  Hello, {$store.age}-year-old {$store.name}!
</div>

However, its behaviour may surprise you if you are not aware of how Svelte works under the hood. Have a look at the two examples below.

Say you want to fetch some additional information based on user’s age:

App.svelte
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<script>
  import { store } from "./stores";

  let drinks = [];
  $: {
    drinks = [];
    const isMinor = $store.age < 18;
    if (isMinor) {
      drinks = ["juice"];
    } else {
      // simulate a slow API
      setTimeout(() => (drinks = ["beer", "wine", "whiskey"]), 1000);
    }
  }
</script>

<label>
  What is your name?
  <input type="text" bind:value={$store.name} />
</label>
<div>
  <span>Hello, {$store.age}-year-old {$store.name}!</span>
  <span>Do you want to drink some {drinks.join(", ")}?</span>
</div>

You would think the highlighted line should only run when the age changes, but it turns out it will be executed every time you modify the name as well.

Okay, that seems odd, but it still makes some sense because we are still using the name field in the component. So you decide to extract the inputs and then insert the age-dependent logic in the AgeForm component:

<script>
  import { store } from "./stores";
  import NameForm from "./NameForm.svelte";
  import AgeForm from "./AgeForm.svelte";
</script>

<div>
  <NameForm />
  <AgeForm />
</div>
<div>
  <span>Hello, {$store.age}-year-old {$store.name}!</span>
</div>
<script>
  import { store } from "./stores";

  $: {
    if ($store.age < 18) {
      alert("You are under 18!");
    }
  }
</script>

<label>
  What is your age?
  <input type="number" bind:value={$store.age} />
</label>

But still, the alert() is triggered when the user modifies the name field. What is happening?

Behind the Scene

To get better understanding, we need to have a look at the runtime codes. Thankfully, REPL shows you the compiled JavaScript code, so let’s inspect the output code for the AgeForm.svelte component in this REPL.

summary When Svelte stores with an array or object inside receive an update, they always schedule an update for all subscribed components. This makes the components to execute the callbacks scheduled by beforeUpdate() or afterUpdate() and trigger reactive variables and statements to recalculate, but does not guarantee DOM updates. The DOM elements will only be modified when the actual values of the variables they refer to change.

Let’s first have a look at how Svelte handles change on <input>.

<script>
  import { store } from "./stores";
</script>

<label>
  What is your name?
  <input type="text" bind:value={$store.name} />
</label>
function instance($$self, $$props, $$invalidate) {
  let $store;
  // ...

  function input_input_handler() {
    $store.name = this.value;
    store.set($store);
  }
  // ...
}

You can see the input handler calls store.set() to update the store values. The store then checks whether the new value is different from previous, before it notifies all of its subscribers. However, when the store contains an array or object, it skips the check, as seen from the comparing algorithm:

export function writable(value, /* ... */) {
  function set(new_value) {
    if (safe_not_equal(value, new_value)) {
      value = new_value;
      // run subscriber callbacks
    }
  }
}
export function safe_not_equal(a, b) {
    return a != a
      ? b == b
      : a !== b
        || (a && typeof a === "object")
        || typeof a === "function";
  }

Hence, all components subscribed to the store will attempt to update even when none of the values changes. For example, alert() will run whenever you click the button below.

<script>
  import { store } from "./stores";
</script>

<label>
  What is your name?
  <input type="text" bind:value={$store.name} />
</label>
<button on:click={() => $store.name = "James"}>
  James-ify me!
</button>

So why do Svelte does not implement shallow equality checking like many other state management libraries, and instead force every component to update? Let’s find out what happens when a component is scheduled to update.

Component Update Cycle

Every component with variables whose values can change in its lifecycle will have its corresponding update method, p(), to instruct how its DOM elements should be updated. For example, here is the update method compiled from NameForm.svelte:

<script>
  import { store } from "./stores";
</script>

<label>
  What is your name?
  <input type="text" bind:value={$store.name} />
</label>
function create_fragment(ctx) {
  // ...
  return {
    // ...
    p(ctx, [dirty]) {
      if (
        dirty & /*$store*/ 1 &&
        input.value !== /*$store*/ ctx[0].name
      ) {
        set_input_value(input, /*$store*/ ctx[0].name);
      }
    },
    // ...
  };
}

The compiled code clearly indicates <input> will be updated when its value does not match $store.name. Thus, components carry out the shallow comparison for the store anyway, so stores do not need to worry about triggering redundant DOM updates.

However, there is still one takeaway; because p() will still be called to do these checks, callback functions scheduled with lifecycle methods like beforeUpdate() and afterUpdate() will be invoked whether or not the elements actually update.

This is the same with reactive statements. If we look at how AgeForm.svelte is compiled, we can see the statement will run if the store has been touched since last update, not just $store.age.

AgeForm.svelte
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<script>
  import { store } from "./stores";

  $: {
    if ($store.age < 18) {
      alert("You are under 18!");
    }
  }
</script>

<label>
  What is your age?
  <input type="number" bind:value={$store.age} />
</label>
41
42
43
44
45
46
47
48
49
50
51
52
53
54
function instance($$self, $$props, $$invalidate) {
  // ...

  $$self.$$.update = () => {
    if ($$self.$$.dirty & /*$store*/ 1) {
      $: {
        if ($store.age < 18) {
          alert("You are under 18!");
        }
      }
    }
  };
  // ...
}

note I was inspired by this amazing presentation and this post series (unfortunately unfinished) to start writing this post. If you want a more in-depth analysis of the Svelte compiler, I highly recommend you to have a look.

Solutions

Now that we understand the reasons behind this seemingly unnecessary “updates”, we can discuss how we can improve our practices.

Don’t worry about it

Seriously. Components constantly calling their update methods will not affect the performance for most cases because going through a couple of if statements is much faster than actually performing DOM manipulations.

On the other hand, if the bottleneck occurs before the DOM is updated, it is desirable to suppress component updates in the first place. Below are a few examples of how you can achieve this.

Multiple stores

The current store syntax introduced in Svelte 3 is designed to encourage developers to work with multiple atomic stores, instead of one giant store like Redux. If we split the store into two separate stores, $name and $age, and the components only subscribe to one of them, there will be no unnecessary updates to begin with.

import { writable } from "svelte/store";

export const name = writable("John");
export const age = writable(18);
<script>
  import { name } from "./stores";
</script>

<label>
  What is your name?
  <input type="text" bind:value={$name} />
</label>

What if the component needs to access multiple stores at once? Of course it can import all of them, or you can derive a read-only store from multiple stores:

import { writable, derived } from "svelte/store";

export const name = writable("John");
export const age = writable(18);

export const selfIntro = derived(
  [name, age],
  ([$name, $age]) => `${$age}-year-old ${$name}`
);
<script>
  import { selfIntro } from "./stores";
</script>

<div>
  <span>Hello, {$selfIntro}!</span>
</div>

Reactive statements

One easy fix that does not involve redesigning of your stores is to assign a reactive variable with $:.

AgeForm.svelte
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<script>
  import { store } from "./stores";

  $: age = $store.age;
  $: {
    if (age < 18) {
      alert("You are under 18!");
    }
  }
</script>

<label>
  What is your age?
  <input
    type="number" bind:value={$store.age}
  />
</label>

In the above example, line 4 will be executed every time the store is updated, but the if statement below will not run unless the actual value of $store.age changes because it now tracks a single variable, rather than an object.

Slice stores

Lastly, you can use derived stores to have the components subscribe to only certain values.

import { writable } from "svelte/store";

export const store = writable({
  name: "John",
  age: 18,
});
<script>
  import { derived } from "svelte/store";
  import { store } from "./stores";

  const age = derived(store, $store => $store.age);
  $: {
    if ($age < 18) {
      alert("You are under 18!");
    }
  }
</script>

<label>
  What is your age?
  <input
    type="number" value={$age}
    on:change={(e) => $store.age = e.target.value}
  />
</label>

Note that because derived stores are read-only, you need to write a custom change handler, instead of using bind:. If you really want to bind to an <input>, we can implement the set() function to turn it into a writable store.

stores.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import { writable, derived } from "svelte/store";

const slice = (parentStore, key) => {
  return {
    ...derived(parentStore, ($store) => $store[key]),
    set: (value) => {
      parentStore.update((prev) => ({ ...prev, [key]: value }));
    },
    update: (updater) =>
      parentStore.update((prev) => ({ ...prev, [key]: updater(prev[key]) })),
  };
};

export const store = writable({
  name: "John",
  age: 18,
});
export const age = slice(store, "age");