- Start Date: 2018-11-10
- RFC PR: #5
- Svelte Issue: (leave this empty)
This RFC proposes a replacement for the existing Store class that satisfies the following goals:
- Works with the Svelte 3 component design outlined in RFC 1
- Allows any given component to subscribe to multiple sources of data from outside the component tree, rather than favouring a single app-level megastore
- Typechecker friendliness
- Adapts to existing state management systems like Redux or TC39 Observables, but does not require them
- Concise syntax that eliminates lifecycle boilerplate
In Svelte 1, we introduced the Store class, which provides a simple app-level state management solution with an API that mirrors that of individual components — get
, set
, on
, fire
and so on. The store can be attached to a component tree (or subtree), at which point it becomes available within lifecycle hooks and methods as this.store
, and in templates by prefixing identifiers with $
. Svelte is then able to subscribe to changes to app-level data with reasonable granularity and zero boilerplate.
In Svelte 2, Store support was turned on by default.
This approach works reasonably well but has some drawbacks:
- It doesn't permit namespacing or nesting; the store becomes a grab-bag of values
- Because of the convenience of the store being auto-attached to components, it becomes a magnet for things that aren't purely related to application state, such as effectful
login
andlogout
methods - Setting deep properties (
foo.bar.baz
etc) is cumbersome - It doesn't play well with TypeScript
- Creating derived ('computed') values involves a slightly weird API
- Working with existing state management systems isn't as natural as it should be
A large part of the reason for the design of the original store — the fact that it auto-attaches to components, for example — is that the cost of using imported objects in Svelte 1 and 2 is unreasonably high. In Svelte 3, per RFC 1, that cost will be significantly reduced. We can therefore pursue alternatives without the aforementioned drawbacks.
Essentially, the shift is from a single observable store of values to multiple observable values. Instead of this...
export default new Store({
user: {
firstname: 'Fozzie',
lastname: 'Bear'
},
volume: 0.5
});
...we create a new writable
value store:
export const user = writable({
firstname: 'Fozzie',
lastname: 'Bear'
});
export const volume = writable(0.5);
Interested parties can read (and write) user
without caring about volume
, and vice versa:
import { volume } from './stores.js';
const audio = document.querySelector('audio');
const unsubscribe = volume.subscribe(value => {
audio.volume = value;
});
Inside a component's markup, a convenient shorthand sets up the necessary subscriptions (and unsubscribes when the component is destroyed) — the $
prefix:
<script>
import { user } from './stores.js';
</script>
<h1>Hello {$user.firstname}!</h1>
This would compile to something like the following:
import { onDestroy } from 'svelte';
import { user } from './stores.js';
function init($$self, $$invalidate) {
let $user;
onDestroy(user.subscribe(value => {
$user = value;
$$invalidate('$user', $user);
}));
$$self.get = () => ({ $user });
}
A store must have a subscribe
method, and it may also have additional methods like set
and update
if it isn't read-only:
const number = writable(1);
const unsubscribe = number.subscribe(value => {
console.log(`value is ${value}`); // logs 1 immediately
});
number.set(2); // logs 2
const incr = n => n + 1;
number.update(incr); // logs 3
unsubscribe();
number.set(4); // does nothing — unsubscribed
An example implementation of this API:
function writable(value) {
const subscribers = [];
function set(newValue) {
if (newValue === value) return;
value = newValue;
subscribers.forEach(s => s[1]());
subscribers.forEach(s => s[0](value));
}
function update(fn) {
set(fn(value));
}
function subscribe(run, invalidate = noop) {
const subscriber = [run, invalidate];
subscribers.push(subscriber);
run(value);
return () => {
const index = subscribers.indexOf(subscriber);
if (index !== -1) subscribers.splice(index, 1);
};
}
return { set, update, subscribe };
}
Some stores are read-only, created with readable
:
const unsubscribe = mousePosition.subscribe(pos => {
if (pos) console.log(pos.x, pos.y);
});
mousePosition.set({ x: 100, y: 100 }); // Error: mousePosition.set is not a function
An example implementation:
function readable(start, value) {
const subscribers = [];
let stop;
function set(newValue) {
if (newValue === value) return;
value = newValue;
subscribers.forEach(s => s[1]());
subscribers.forEach(s => s[0](value));
}
return {
subscribe(run, invalidate = noop) {
if (subscribers.length === 0) {
stop = start(set);
}
const subscriber = [run, invalidate];
subscribers.push(subscriber);
run(value);
return function() {
const index = subscribers.indexOf(subscriber);
if (index !== -1) subscribers.splice(index, 1);
if (subscribers.length === 0) {
stop && stop();
stop = null;
}
};
}
};
}
const mousePosition = readable(function start(set) {
function handler(event) {
set({
x: event.clientX,
y: event.clientY
});
}
document.addEventListener('mousemove', handler);
return function stop() {
document.removeEventListener('mousemove', handler);
}
});
A store can be derived from other stores with derive
:
const a = writable(1);
const b = writable(2);
const c = writable(3);
const total = derive([a, b, c], ([a, b, c]) => a + b + c);
total.subscribe(value => {
console.log(`total is ${value}`); // logs 'total is 6'
});
c.set(4); // logs 'total is 7'
Example implementation:
function derive(stores, fn) {
const single = !Array.isArray(stores);
if (single) stores = [stores];
const auto = fn.length === 1;
let value = {};
return readable(set => {
let inited = false;
const values = [];
let pending = 0;
const sync = () => {
if (pending) return;
const result = fn(single ? values[0] : values, set);
if (auto && (value !== (value = result))) set(result);
}
const unsubscribers = stores.map((store, i) => store.subscribe(
value => {
values[i] = value;
pending &= ~(1 << i);
if (inited) sync();
},
() => {
pending |= (1 << i);
})
);
inited = true;
sync();
return function stop() {
run_all(unsubscribers);
};
});
}
In the example above,
total
is recalculated immediately whenever the values ofa
,b
orc
are set. In some situations that's undesirable; you want to be able to seta
,b
andc
withouttotal
being recalculated until you've finished. That could be done by putting theset(fn(...values))
in a microtask, but that has drawbacks too. (Of course, that could be a decision left to the user.) Is this a fatal flaw in the design — should we strive for pull-based rather than push-based derived values? Or is it fine in reality?
Derived stores are, by nature, also read-only. They could be used, for example, to filter the items in a todo list:
<script>
import { writable, derive } from 'svelte/store.js';
import { todos } from './stores.js';
const hideDone = writable(false);
const filtered = derive([todos, hideDone], (todos, hideDone) => todos.filter(todo => {
return hideDone ? !todo.done : true;
}));
</script>
<label>
<input type=checkbox checked={$hideDone} on:change="{e => hideDone.set(e.target.checked)}">
hide done
</label>
{#each $filtered as todo}
<p class="{todo.done ? 'faded' : ''}">{todo.description}</p>
{/each}
There is a stage 1 proposal for an Observable object in JavaScript itself.
Cards on the table: I'm not personally a fan of Observables. I've found them to be confusing and awkward to work with. But there are particular reasons why I don't think they're a good general solution for representing reactive values in a component:
- They don't represent a single value changing over time, but rather a stream of distinct values. This is a subtle but important distinction
- Two different subscribers to the same Observable could receive different values (!), where as in a UI you want two references to the same value to be guaranteed to be consistent
- Observables can 'complete', but declarative components (in Svelte and other frameworks) deliberately do not have a concept of time. The two things are incompatible
- They have error-handling semantics that are very often redundant (what error could occur when observing the mouse position, for example?). When they're not redundant (e.g. in the case of data coming over the network), errors are perhaps best handled out-of-band, since the goal is to concisely represent the value in a component template
Of course, some Observables are suitable for representing reactive values in a template, and they could easily be adapted to work with this design:
function adaptor(observable) {
return {
subscribe(fn) {
const subscriber = observable.subscribe({
next: fn
});
return subscriber.unsubscribe;
}
}
}
const observable = Observable.of('red', 'green', 'blue');
const store = adaptor(observable);
const unsubscribe = store.subscribe(color => {
console.log(color); // logs red, then green, then blue
});
More broadly, the same technique will work with existing state management libraries, as long as they expose the necessary hooks for observing changes. (I've found this to be difficult with MobX, but perhaps I'm just not sufficiently familiar with that library — would welcome submissions.)
// src/redux.js
import { createStore } from 'redux';
export const reduxStore = createStore((state = 0, action) => {
switch (action.type) {
case 'INCREMENT':
return state + 1
case 'DECREMENT':
return state - 1
default:
return state
}
});
function adaptor(reduxStore) {
return {
subscribe(fn) {
return reduxStore.subscribe(() => {
fn(reduxStore.getState());
});
}
};
}
export const store = adaptor(reduxStore);
<!-- src/Counter.html -->
<script>
import { reduxStore, store } from './redux.js';
</script>
<button on:click="{() => reduxStore.dispatch({ type: 'INCREMENT' })}">
Clicks: {$store}
</button>
import { writable } from 'svelte/store.js';
import { produce } from 'immer';
function immerObservable(data) {
const store = writable(data);
function update(fn) {
store.update(state => produce(state, fn));
}
return {
update,
subscribe: store.subscribe
};
}
const todos = immerObservable([
{ done: false, description: 'walk the dog' },
{ done: false, description: 'mow the lawn' },
{ done: false, description: 'do the laundry' }
]);
todos.update(draft => {
draft[0].done = true;
});
import { readable } from 'svelte/store.js';
import { value, computed } from 'shiz';
const a = value(1);
const b = computed([a], ([a]) => a * 2);
function shizObservable(shiz) {
return readable(function start(set) {
return shiz.on('change', () => {
set(shiz.get());
});
}, shiz.get());
}
const store = shizObservable(b);
const unsubscribe = store.subscribe(value => {
console.log(value); // logs 2
});
a.set(2); // logs 4
At present, Store
gets privileged treatment in Sapper apps. A store instance can be created per-request, for example to contain user data. This store is attached to the component tree at render time, allowing <span>{$user.name}</span>
to be server-rendered; its data is then passed to a client-side store.
It's essential that this functionality be preserved. I'm not yet sure of the best way to achieve that. The most promising suggestion is that we use regular props instead, passed into the top-level component. (In some ways this would be more ergonomic, since the user would no longer be responsible for setting up the store client-side.)
Potential corner-cases to discuss:
- What happens if a subscriber causes another subscriber to be removed (e.g. it results in a component subtree being destroyed)?
- Is
$user.name
ambiguous (i.e. isuser
the store, oruser.name
?) and if so how do we resolve the ambiguity- What happens if
$user
is declared in a scope that has a storeuser
? Do we just not subscribe?
As with RFC 1, it's crucial that this be introduced with ample demos of how the $
prefix works, in terms of the generated code.
It's arguably simpler to teach than the existing store, since it's purely concerned with data, and avoids the 'magic' of auto-attaching.
Like RFC 1, this is a breaking change, though RFC 1 will break existing stores anyway. The main reason not to pursue this option would be that the $
prefix is overly magical, though I believe the convenience outweighs the modest learning curve.
Another potential drawback is that anything that uses a store (except the markup) must itself become a reactive store; they are red functions. But this problem is presumably fundamental, rather than an avoidable consequence of the approach we've happened to choose.
RFC 3 presents an escape hatch to this problem
- The existing store (but only at the top level; there is no opportunity to add stores at a lower level in the new design)
- Baking in an existing state management library (and its opinions)
- Using TC39 Observables
- Not having any kind of first-class treatment of reactive values, and relying on the reactive assignments mechanism exclusively
- The Sapper question
- The exact mechanics of how typechecking would work