Vladimir Klepov as a Coder

Svelte stores: the curious parts

We've already learnt a lot about svelte's reactivity system — the primary way to work with state in svelte components. But not all state belongs in components — sometimes we want app-global state (think state manager), sometimes we just want to reuse logic between components. React has hooks, Vue has composables. For svelte, the problem is even harder — reactive state only works inside component files, so the rest is handled by a completely separate mechanism — stores. The tutorial does a decent job of covering the common use cases, but I still had questions:

  1. What's the relationship between the stores? Are they built on some common base?
  2. Is it safe to use { set } = store as a free function?
  3. How does get(store) receive the current value if it's not exposed on the object?
  4. Does set() trigger subscribers when setting the current value?
  5. What's the order of subscriber calls if you set() inside a subscriber?
  6. Does derived listen to the base stores when it's not observed?
  7. Will changing two dervied dependencies trigger one or two derived computations?
  8. Why does subscribe() have a second argument?
  9. What is $store sytax compiled to?

In this article, I explore all these questions (and find a few svelte bugs in the process).

writable is the mother store

Svelte has 3 built-in store types: writable, readable, and derived. However, they are neatly implemented in terms of one another, taking only 236 lines, over half of which is TS types and comments.

The implementation of readable is remarkably simple — it creates a writable, and only returns its subscribe method. Let me show it in its entirety:

const readable = (value, start) => ({
subscribe: writable(value, start).subscribe
});

Moreover, derived is just a special way of constructing readable:

export function derived(stores, fn, initial_value) {
// ...some normalization
return readable(initial_value, /* some complex code */);
}

While we're at it, note that update method of a writable store is a very thin wrapper over set: fn => set(fn(value)).

All in all:

  • writable is the OG store,
  • readable just removes set & update methods from a writable,
  • derived is just a predefined readable setup,
  • update is just a wrapper over set.

This greatly simplifies our analysis — we can just investigate writable arguments, subscribe, and set — and our findings also hold for other store types. Well done, svelte!

Store methods don't rely on this

Writable (and, by extension, readable and derived) is implemented with objects and closures, and does not rely on this, so you can safely pass free methods around without dancing with bind:

const { subscribe, set } = writable(false);
const toggle = { subscribe, activate: () => set(true) };

However, arbitrary custom stores are not guaranteed to have this trait, so it's best to stay safe working with an unknown store-shaped argument — like svelte itself does with readonly:

function readonly(store) {
return {
subscribe: store.subscribe.bind(store),
};
}

Subscriber is invoked immediately

As svelte stores implement observable value pattern, you'd expect them to have a way to access current value via store.get() or store.value — but it's not there! Instead, you use the special get() helper function:

import { get } from 'svelte/store'
const value = get(store);

But, if the store does not expose a value, how can get(store) synchronously access it? Normally, the subscribers are only called on change, which can occur whenever. Well, svelte subscribe is not your average subscribe — calling subscribe(fn) not only starts listening to changes, but also synchronously calls fn with the current value. get subscribes to the store, extracts the value from this immediate invocation, and immediately unsubscribes — like this:

let value;
const unsub = store.subscribe(v => value = v);
unsub();

The official svelte tutorial section on custom stores says: as long as an object correctly implements the subscribe method, it's a store. This might bait you into writing "custom stores" with subscribe method, not based off of writable. The trick word here is correctly implements — even based on the tricky subscribe self-invocation it's not an easy feat, so please stick to manipulations with readable / writable / derived.

set() is pure for primitives

writable stores are pure in the same sense as svelte state — notifications are skipped when state is primitive, and the next value is equal to the current one:

const s = writable(9);
// logs 9 because immediate self-invocation
s.subscribe(console.log);
// does not log
s.set(9);

Object state disables this optimization — you can pass a shallow equal object, or the same (by reference) object, the subscribers will be called in any case:

const s = writable({ value: 9 });
s.subscribe(console.log);
// each one logs
s.update(s => s);
s.set(get(s));
s.set({ value: 9 });

On the bright side, you can mutate the state in update, and it works:

s.update(s => {
s.value += 1;
return s
});

Subscriber consistency

Normally, store.set(value) synchronously calls all subscribers with value. However, a naive implementation will shoot you in the foot when updating a store from within a subscriber (if you think it's a wild corner case — it's not, it's how derived stores work):

let currentValue = null;
const store = naiveWritable(1);
store.subscribe(v => {
// let's try to avoid 0
if (v === 0) store.set(1);
})
store.subscribe(v => currentValue = v);

If we now call set(0), we intuitively expect both the store's internal value and currentValue to be 1 after all callbacks settle. But in practice it can fail:

  1. Store value becomes 0;
  2. First subscriber sees 0, calls set(1), then:
    1. Store value becomes 1;
    2. set(1) synchronously invokes all subscribers with 1;
    3. First subscriber sees 1, does nothing;
    4. Second subscriber is called with 1, sets currentValue to 1;
    5. First subscriber run for 0 is completed, continuing with the initial updates triggered by set(0)
  3. Second subscriber is called with 0, setting currentValue to 0;
  4. Bang, inconsistent state!

This is very dangerous territory — you're bound to either skip some values, get out-of-order updates, or have subscribers called with different values. Rich Harris has taken a lot of effort to provide the following guarantees, regardless of where you set the value:

  1. Every subscriber always runs for every set() call (corrected for primitive purity).
  2. Subscribers for one set() run, uninterrupted, after one another (in insertion order, but I wouldn't rely on this too much).
  3. Subscribers are invoked globally (across all svelte stores) in the same order as set calls, even when set calls are nested (called from within a subscriber).
  4. All subscribers are called synchronously within the outermost set call (the one outside any subscriber).

So, in our example, the actual callback order is:

  1. subscriber 1 sees 0, calls set(1)
  2. subscribers calls with 1 are enqueued
  3. subscriber 2 sets currentValue = 0
  4. subscriber 1 runs with 1, does nothing
  5. subscriber 2 sets currentValue = 1

Since the callback queue is global, this holds even when updating store B from a subscriber to store A. One more reason to stick with svelte built-in stores instead of rolling your own.

Derived is lazy

derived looks simple on the surface — I thought it just subscribes to all the stores passed, and keeps an up-to-date result of the mapper function. In reality, it's smarter than that — subscription and unsubscription happens in the start / stop handler, which yields some nice properties:

  1. Subscriptions to base stores are automatically removed once you stop listening to the derived store, no leaks.
  2. Derived value and subscriptions are reused no matter how many times you subscribe to a derived store.
  3. When nobody is actively listening to a derived store, the mapper does not run.
  4. The value is automatically updated when someone first subscribes to the derived store (again, courtesy of subscribe self-invocation).

Very, very tastefully done.

Derived is not transactional

While lazy, derived is not transactional, and not batched — synchronously changing 2 dependencies will trigger 2 derivations, and 2 subscriber calls — one after the first update, and one after the second one.

In this code sample, we'd expect left + right to always be 200 (we synchronously move 10 from left to right), there's a glimpse of 190 (remember, the subscribers are synchronously called during set):

const left = writable(100);
const right = writable(100);
const total = derived([left, right], ([x, y]) => {
console.log('derive', x, y);
return x + y;
});
total.subscribe(t => console.log('total', t));

const update = () => {
// try to preserve total = 200
left.update(l => l - 10);
// ^^ derives, and logs "total 190"
right.update(r => r + 10);
// ^^ derives, and logs "total 200"
};

This isn't a deal breaker, svelte won't render the intermediate state, but it's something to keep in mind, or you get hurt:

const obj = writable({ me: { total: 0 } });
const key = writable('me');
const value = derived([obj, key], ([obj, key]) => obj[key].total);

// throws, because { me: ... } has no 'order' field
key.set('order');
obj.set({ order: { total: 100 } });

The mysteryous subscriber-invalidator

Looking at subscribe() types, you may've noticed the mysterious second argument — invalidate callback. Unlike the subscriber, it's not queued, and is always called synchronously during set(). The only place I've seen an invalidator used in svelte codebase is inside derived — and, TBH, I don't understand its purpose. I expected it to stabilize derived chains, but it's not working. Also, the TS types are wrong — the value is never passed to invalidator as an argument. Verdict: avoid.

$-dereference internals

As you probably know, svelte components have a special syntax sugar for accessing stores — just prefix the store name with a $, and you can read and even assign it like a regular reactive variable — very convenient:

import { writable } from 'svelte/store';
const value = writable(0);
const add = () => $value += 1;

<button on:click={add}>
{$value}
</button>

I always thought that $value is compiled to get, $value = v to value.set(v), and so on, with a subscriber triggering a re-render in some smart way, but it's not the case. Instead, $value becomes a regular svelte reactive variable, synchronized to the store, and the rest is handled by the standard svelte update mechanism. Here's the compilation result:

// the materialized $-variable
let $value;
// the store
const value = writable(0);

// auto-subscription
const unsub = value.subscribe(value, value => {
$$invalidate(0, $value = value)
});
onDestroy(unsub);

const add = () => {
// assign to variable
$value += 1;
// update store
value.set($value);
};

In plain English:

  1. $store is a real actual svelte reactive variable.
  2. store.subscribe updates the variable and triggers re-render.
  3. The unsubscriber is stored and called onDestroy.
  4. AFAIK, store.update is never used by svelte.
  5. Assignments to $store simultaneously mutate $store variable without invalidating and triggering re-render and call store.set, which in turn enqueues the update via $$invalidate

The last point puts us in a double-source-of-truth situation: the current store value lives both in the $store reactive variable, and inside store itself. I expected this to cause some havok in an edge case, and so it does — if you patch store.set method to skip some updates, the $-variable updates before your custom set runs, and the two values go out of sync as of svelte@3.59.1:

const value = {
...writable(0),
// prevent updates
set: () => {}
};
const add = () => $value += 1;
let rerender = {};
$: total = $value + (rerender ? 0 : 1);

{total}
<button on:click={add}>increment</button>
<button on:click={() => rerender = {}}>
rerender
</button>

To summarize:

  1. Both readable and derived are built on top of writable — readable only picks subscribe method, derived is a readable with a smart start / stop notifier.
  2. Built-in stores don't rely on this, so you can safely use their methods as free functions.
  3. Calling subscribe(fn) immediately invokes fn with the current value — used in get(store) to get the current value.
  4. Calling set() with the current value of the store will skip notifying subscribers if the value is primitive. set() on object state always notifies, even if the object is same, by reference, as the current state.
  5. The subscribers for a single set() run after one another. If a subscriber calls set, this update will be processed once the first set() is fully flushed.
  6. derived only subscribes to the base stores and maps the value when someone's actively listening to it.
  7. When synchronously changing two dependencies of derived, the mapper will be called after the first change. There's no way to batch these updates.
  8. subscribe() has a second argument — a callback that's called synchronously during set(). I can't imagine a use case for it.
  9. $store syntax generates a regular svelte reactive variable called $store, and synchronizes it with the store in a subscriber.

If you learn one thing from this article — svelte stores are thoughtfully done and help you with quite a few corner-cases. Please avoid excessive trickery, and build on top of the svelte primitives. In the next part of my svelte series, I'll show you some neat tricks with stores — stay tuned on twitter!

Hello, friend! My name is Vladimir, and I love writing about web development. If you got down here, you probably enjoyed this article. My goal is to become an independent content creator, and you'll help me get there by buying me a coffee!
More? All articles ever
Older? Svelte reactivity — an inside and out guide Newer? I conducted 60 interviews in 2 months — here's what I learned