Skip to content

Instantly share code, notes, and snippets.

@Rich-Harris
Created November 24, 2017 16:44
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Rich-Harris/c81511a4b21ee48a79ba09c1562e25a7 to your computer and use it in GitHub Desktop.
Save Rich-Harris/c81511a4b21ee48a79ba09c1562e25a7 to your computer and use it in GitHub Desktop.
how svelte/store could work

Bear with me while I think aloud about this (please comment there, not here!). Goals:

  • Minimal boilerplate
  • Familiar API
  • Preserve Svelte's built-in optimisations
  • Support use cases like hot-reloading and custom devtools

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>

Is this a breaking change?

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.

Ok so how would this actually work

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);
});

Other thoughts

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);
});

Feedback pls

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?

@fiskgrodan
Copy link

A svelte store library with minimal boilerplate would be great!

I'm no export on central store management systems so take what I write with some salt.

The central state management library I personally have the most experience with is vuex. Which I think has a pretty good pattern/syntax. And maybe it could give some inspiration for a svelte store library!

Unlike redux the state in vuex is mutated. But it is always mutated in a predictable fashion. Each mutations creates a state-snapshots for devtool debugging with time-traveling etc. I guess this could perhaps be beneficial for frameworks that works like vue and svelte which uses observers and computed properties etc?

The store is injected into all the child components of the root component as this.$store. Which saves a bit of boilerplating for the components.

Vuex concepts:

  • State is the single source of truth and can only be mutated with mutation functions. You can access data directly with computed properties in the components for example like this:
computed: {
  count() {
    return this.$store.state.count
  }
}
  • Getters are read only functions to access data from the stores state. You can use them in component computed properties for example like this:
computed: {
  doneTodosCount() {
    return this.$store.getters.doneTodosCount
  }
}
  • Mutations are functions to make synchronous changes to the state. Sort of similar/comparable to redux actions. You can map them to component methods.

  • Actions are functions to make async changes to the state. You dispatch an actions that commits a mutation when it resolves. Sort of similar to redux action creators with async middlewares like redux-thunk.

  • Modules is a nifty feature to split up the store into different sub-parts. For example for different page routes. These modules can be lazy loaded on demand when they are needed. For example the first time you enter a route page.

The drawback of mapping the getters to component computed properties and mapping mutations and actions to component methods is a bit more boilerplate. But I think it's worth it because it makes it clear in the components code what specific store functionality that component needs. And those computed properties and methods can be used as you would use any regular computed property or method in the components without any special $-syntax.

I'm sorry if I sound to much of a vuex fanboi ;p. But i think its pattern and syntax is pretty good and could maybe be a good inspiration.

When there's an early version of the svelte centralized state management library (is there a name for it?) I'd be very happy to try it out to help find bugs, edge cases and give general feedback on it!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment