Bear with me while I think aloud about this (please comment there, not here!). Goals:
Let’s start with a single store that is external to the component tree. Our top-level <App>
component connects to it:
// main.js
import App from './App.html';
import store from './store.js';
const app = new App({
target: document.querySelector('main'),
store
});
Let’s say store.js
exports an instance of Store
:
// store.js
import Store from 'svelte/store';
class MyStore extends Store {
setName(name) {
this.set({ name });
}
}
export default new MyStore({
name: 'world'
});
A Store
instance has get
, set
and observe
methods, which behave exactly the same as their Svelte component counterparts. By subclassing Store
, we can add new methods, which are roughly analogous to actions in Redux.
Our components must bind to the store. One way to do that would be to agree that any properties prefixed with $
belong to the store:
<!-- App.html -->
<h1>Hello {{$name}}!</h1>
<NameInput/>
<script>
import NameInput from './NameInput.html';
export default {
components: { NameInput }
};
</script>
<!-- NameInput.html -->
<input placeholder='Enter your name' on:input='store.setName(this.value)'>
The <NameInput>
component could equally use bind:value=$name
.
By using the $
convention, we could use store values in computed properties:
<script>
export default {
computed: {
visibleTodos: ($todos, $filter) => $todos.filter(todo => todo.state === $filter)
}
};
</script>
Observing properties could be done like so:
<script>
export default {
oncreate() {
this.store.observe('name', (newName, oldName) => {
console.log(`name changed from ${oldName} to ${newName}`);
});
}
};
</script>
If people are already using $
-prefixed properties, then this would be a breaking change if {{$name}}
was automatically reinterpreted. We could skirt around that by adding a store: true
compiler option that becomes the default in v2.
Each component would know, by virtue of the $
prefixes, which properties it was interested in. So Svelte would generate some code like this in the component constructor:
this.store.addDependent(this, ['name']);
this.on('destroy', () => {
this.store.removeDependent(this);
});
At the end of each set
, the store would do something like this:
this.dependents.forEach(({ component, props }) => {
const componentState = {};
let dirty = false;
props.forEach(prop => {
if (prop in changed) { // `changed`
componentState['$' + prop] = storeState[prop];
dirty = true;
}
});
if (dirty) component.set(state);
});
Because store methods are just that, we can (for example) perform asynchronous actions without introducing any new concepts:
class MyStore extends Store {
async fetchStockPrices(ticker) {
const token = this.token = {};
const prices = await fetch(`/api/prices/${ticker}`).then(r => r.json());
if (token !== this.token) return; // invalidated by subsequent request
this.set({ prices });
}
}
(Of course, with {{#await ...}}
you wouldn’t even need that…
We could have an onchange
method that would facilitate adaptors for things like localStorage
:
const store = new Store(getFromLocalStorage());
store.onchange((state, changed) => {
// `changed` would be an object like `{ name: true }`
setLocalStorage(state);
});
This is a fairly different approach to Redux — it doesn’t emphasise read-only state, for example. We lose these benefits:
This ensures that neither the views nor the network callbacks will ever write directly to the state. Instead, they express an intent to transform the state. Because all changes are centralized and happen one by one in a strict order, there are no subtle race conditions to watch out for. As actions are just plain objects, they can be logged, serialized, stored, and later replayed for debugging or testing purposes.
My sense is that that’s probably ok — if you’re building an application of such complexity that you need Redux, you can still use Redux — it’s fairly straightforward to do so. Most apps don’t fall into that category. In fact, you could implement dispatch
on your Store
subclass if you wanted to.
But I would be particularly interested to hear from people who have built large apps using tools like Redux and MobX — does the approach outlined here have any major pitfalls to be aware of?