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:
- What's the relationship between the stores? Are they built on some common base?
- Is it safe to use
{ set } = store
as a free function? - How does
get(store)
receive the current value if it's not exposed on the object? - Does
set()
trigger subscribers when setting the current value? - What's the order of subscriber calls if you
set()
inside a subscriber? - Does
derived
listen to the base stores when it's not observed? - Will changing two
dervied
dependencies trigger one or two derived computations? - Why does
subscribe()
have a second argument? - 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 removesset
&update
methods from a writable,derived
is just a predefinedreadable
setup,update
is just a wrapper overset
.
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:
- Store value becomes 0;
- First subscriber sees 0, calls
set(1)
, then:- Store value becomes 1;
set(1)
synchronously invokes all subscribers with 1;- First subscriber sees 1, does nothing;
- Second subscriber is called with 1, sets
currentValue
to 1; - First subscriber run for 0 is completed, continuing with the initial updates triggered by
set(0)
- Second subscriber is called with 0, setting
currentValue
to 0; - 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:
- Every subscriber always runs for every
set()
call (corrected for primitive purity). - Subscribers for one
set()
run, uninterrupted, after one another (in insertion order, but I wouldn't rely on this too much). - 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). - All subscribers are called synchronously within the outermost
set
call (the one outside any subscriber).
So, in our example, the actual callback order is:
- subscriber 1 sees 0, calls
set(1)
- subscribers calls with
1
are enqueued - subscriber 2 sets
currentValue = 0
- subscriber 1 runs with 1, does nothing
- 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 subscribe
s 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:
- Subscriptions to base stores are automatically removed once you stop listening to the derived store, no leaks.
- Derived value and subscriptions are reused no matter how many times you subscribe to a derived store.
- When nobody is actively listening to a derived store, the mapper does not run.
- 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:
$store
is a real actual svelte reactive variable.store.subscribe
updates the variable and triggers re-render.- The unsubscriber is stored and called
onDestroy
. - AFAIK,
store.update
is never used by svelte. - Assignments to
$store
simultaneously mutate$store
variable without invalidating and triggering re-render and callstore.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:
- Both
readable
andderived
are built on top ofwritable
— readable only pickssubscribe
method, derived is a readable with a smart start / stop notifier. - Built-in stores don't rely on
this
, so you can safely use their methods as free functions. - Calling
subscribe(fn)
immediately invokesfn
with the current value — used inget(store)
to get the current value. - 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. - The subscribers for a single
set()
run after one another. If a subscriber callsset
, this update will be processed once the firstset()
is fully flushed. derived
only subscribes to the base stores and maps the value when someone's actively listening to it.- When synchronously changing two dependencies of
derived
, the mapper will be called after the first change. There's no way to batch these updates. subscribe()
has a second argument — a callback that's called synchronously duringset()
. I can't imagine a use case for it.$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!