egoist / zerotwo

😈 Refined state containter for Vue.js
75 stars 6 forks source link

new: class API #10

Open egoist opened 6 years ago

egoist commented 6 years ago

How you would use it now:

store.js:

import { zerotwo, Store, computed, action } from 'zerotwo'

Vue.use(zerotwo)

class MyStore extends Store {
  state = {
    count: 0
  }

  @action
  increment = () => this.state.count++

  @computed
  doubleCount() {
    return this.state.count * 2
  }
}

export default MyStore

App.vue:

<template>
  <div id="app">
    <button @click="$store.increment">
      {{ $store.state.count }} and {{ $store.doubleCount }}
    </button>
  </div>
</template>

<script>
import store from './store'

export default {
  store
}
</script>

davidm-public commented 6 years ago

@egoist Very nice, and very simple, just two decorations @store and @computed, I like it!

One issue: I question the pattern (or anti-pattern) of adding the store to the root Vue instance.

It causes of a couple of problems:

a) using this.$store explicitly, or even implicitly, loses all type information. We'd need to make that Vue instance a Generic to get that type information back. ie: need to re-write Vue itself in a more type friendly way!

We're just 'complecting' the whole framework, to use Rich Hickey's phrase. see slide #14: this is probably the root cause of the frustration with Vuex: https://raw.githubusercontent.com/richhickey/slides/master/simplicitymatters.pdf https://www.infoq.com/presentations/Simple-Made-Easy

b) I suspect that it's also a reason for the problems with Hot Reloading, just a hunch.

If folks really want that pattern (or anti-pattern), then the vue-property-decorators @provide and @inject could be applied to the root component and any children, to have the store appear in the children of the root, with type information.

In summary: very nice pattern, but make the Inject/Provide of the store explicit so that the store can be typed without changing the rest of Vue.

David

egoist commented 6 years ago

I just added @action decorator, it's not really required but we can use it to add vue-devtools support

Jacknq commented 6 years ago

Not bad, I like inspirational and simplistic tuex (https://github.com/Raiondesu/Tuex) way, are the decorators really needed?

egoist commented 6 years ago

we don't need decorators at all if we don't use the class based API 😄

btw here's also a decorate function so you don't really need the decorator syntax:

import { Store, decorate, computed } from 'zerotwo'

class CounterStore extends Store {
    get doubleCount() {
        return this.state.count * 2
    }
}

decorate(CounterStore, {
    doubleCount: computed
})
davidm-public commented 6 years ago

@egoist @Jack85

I have given this a bit more thought.

Here is my proposal:

a) Introduce a single new concept, a Store, using either @store decorator, or alternatively, a VueStore class that extends the Vue class.
This decorator will instrument the class to:

b) Drop the namespace concept for modules (ie: use existing module system).

c) Drop actions, mutations, getters, mapgetters, mapsetters, etc, and just use get and set on the vue instance. (why do you need @action again?).

d) Don't inject store into a root Vue instance, rather use @Provide on a root and @Inject in the children. This will allow its lifecycle can be manage explicitly if needed. And, more importantly, you get free typing.

e) @Store or VueStore would have the hooks to connect to vue-devtools as needed.

(best I can tell, at the end of the day, the most valuabe thing that Vuex adds to a regular Vue component is the ability to plug into devtools, capture state change events, time-travel etc. no?).

f) Optionally you could decorate nested attributes and/or methods in the store so that they can be Injected/Provided individually.

So, from a developer perspective, it would look like this:

1) Store:


// store.ts:

@store
@component
export class MyStore extends Vue {
    a : number
    get double() { return this.a * 2}
    set mutate_a(n:number) {this.a = n}
}

alternatively, if you don't like decorators, a VueStore that extends Vue could be used, ie:

export class MyStore extends VueStore{} 

2) Root component:

<template>
   <div>App</div>
<template>
<script lang="ts">
    import Vue from 'vue'
    import {Component} from 'vue-property-decorator'

    // root component:

    @Component
    export default class App extends Vue {
          @Provide store:MyStore; // this store will be available in any children
    }
</script>

3) Child Component:

<template>
   <div>Child</div>
<template>
<script lang="ts">
    import Vue from 'vue'
    import {Component} from 'vue-property-decorator'

    // child component:

    @Component
    export default class Child extends Vue {
          @Inject store:MyStore; // the store provided by the root
    }
</script>

  // with modules:

  @store
  export class MyStore {
       module_a: ModuleA
       module_b: ModuleB
  }

Benefits:

a) no need to invent new concepts, such as actions, mutations, getters, state, namespaces, mapgetters, mapmutations, ActionContext, GetterTree, MutatorTree, etc, etc. just leverage the existing getters/setters/modules already provided by Typescript (and JS) !

b) you'd get full type information for free from the injected instance (!!)

c) you'd be able to manage the lifecycle of the store explicitly.

This would make it easier to work with 3rd party components like AgGrid, which manage their own internal state, using beforeMount(), so they don't properly hot-reload vuex stores - it seems that handing store lifecycle management over to the Vue instance breaks the hot-reloading of components that make different life-cycle assumptions (as best I can tell), that particular bug has not been fixed for over 2 years.

d) You'd be able to swap in/out other storage systems (RxJs, MobX) much more easily, using @Inject/@Provide because the model and the store are decoupled and not 'complected'.

e) No need to use strings anywhere, no need for complicated mapping of mutators and getters.

f) no need for any boilerplate, just one new decorator @store.

g) No need to create and manage a separate module namespace system (why do we need that again?) Why treat these as different from any other modules in the project?

Regarding implementation:

Just some initial thoughts, I'll try some of this out when I have a few spare cycles. I guess I'm questioning why we need getters, actions, mutations, and namespaces, when we already have all of these with get, set, commonjs, etc.

David

Jacknq commented 6 years ago

Good points, way you really want to call mutations and actions is something like store.state.increment(2); that guy should handle all devtools stuff, timetravel, subriptions etc. At the same time you dont have to worry about strings anymore, because you have intellisense in place on every muation implemented! this is a killer feature. Definitely agree that less is more in vuex, more simple the api and usage is better. Pluggable architecture needed for modules, decorators might be, i prefer less steps that produce more. Just think how simple is defining vue class component nowdays, similar could be vuex . Question is why would you inject store, when that is under $store in every component, maybe for modules and I would be again thinking how to avoid those strings 'vuexmodulename'. Modules could be some lazy loaded complex object tree as you mentioned, again with intellisense.

davidm-public commented 6 years ago

why would you inject store, when that is under $store in every component

I gave a couple reasons above:

a) Typing:

this.$store is untyped, and unless you modify Vue itself,
ie: Vue<MyStoreType> you're not going to get type information on this.$store.

And given that we are talking about the state of the application, that's the one part where you really need typing information!

Whereas: @Inject store:MyStoreType gives you full type information.

btw - I have not seen any downside to importing the store into each Vue child instance, even without Provide/Inject.

b) putting the store instance in the root Vue instance complicates the lifecycle, especially with 3rd party components and hot-reloading.

eg, if you use a component like AgGridVue (arguably the best datatable for HTML5), then you have to figure out how to deal with it's livecycle (it is maintaining it's own state, using beforeMount()), especially with hot-reloading otherwise you end up with beauties like this:

Uncaught TypeError: Cannot read property 'getters' of undefined

btw, 'getters` in that error could be 'actions', 'mapgetters', various hooks, etc.

Additionally because these are lifecycle bugs in an asynchronous runtime, it's pretty hard to reproduce, so if you look through the vuex issue list you'll see that most of the time the team just closes these issues because they can't be reproduced.

here is an example: https://github.com/vuejs/vuex/issues/264
(there are dozens of such examples out there).

In the case of the AgGridVue example, presumably the combination of: hot-reloading, vue instance lifecycle management, and 3rd party state management are out of sync. So, to debug, you have to understand Vue internals, Webpack HMR internals, and AgGridVue internals, not easy.

By using @Provide/@Inject you have hooks to explicitly handle those cases (and to diagnose them, just using devtools).

c) You'd be able to swap in and out other state management solutions (eg RxJS, MobX, etc).

d) You'd have the option to separate your stores altogether.

(BTW I like the idea of a single store, so I am NOT advocating multiple stores in a single application, and current devtools wouldn't be able to handle that anyway, BUT, there are probably edge cases where that's useful and preferable to a modular single store, again, I'm not advocating that, just pointing out the decoupling the model from the view, and adding a level of indirection, open up additional options to handle edge cases.

e.g. you might want to add options on a per-component basis, eg:

@Inject({beforeMount:true}) store:MyStoreType (note that's a contrived example, I honestly don't know where in the lifecycle this.$store is added).

So, now let me ask the inverse question:

What is the benefit of coupling the store to the view with this.$store ?

Dave

Jacknq commented 6 years ago

What is the benefit of coupling the store to the view with this.$store ?

Well thats quite straightforward, since you have some variables in store that are defining look and feel of your app, like date formats, culture etc, its quite clear that most of the components would need something from store. Therefore writing every time provide inject seems as lots of repetitive work and hassle (write less do more principle - i use it a lot), it makes sense to be some kind of lazy loaded variable.

a) Typing: this.$store is untyped, and unless you modify Vue itself,

Im using typescript (I was playing with babel and flow also, but ts seems to offer less configuring.) and there if you make a small trick like somewhere in your base class if you assigned typed store into $store, (alternatively you can create typed computed prop) like this $store = typedstore from that moment onwards ts know the type of it. Sure there is some stuff under the hood i dont see how its loaded or lazy loaded store etc.

b,c,d i would be happy to see some live examples of those approaches and tutorial why its beneficial - this is hard to find in between, but needed to share the knowledge. Some kind of real world library (not helloworld) examples official way in ts world... examples, examples, examples this is where people learn a lot.

davidm-public commented 6 years ago

@Jack85 I agree re need for examples, so I'll put up an example of using Vuex and Typescript in a way that gives great typing, without putting store in a Vue root, without using provide/inject, without using vuex-class, without using strings, without using mapgetters or mapsetters, and without using your trick of assigning $store to a variable (I'm not a fan of that trick, even if it worked in Webstorm).

Jacknq commented 6 years ago

Ok, great. You would rather be hanging on interfaces and possibly injecting it, it adds often unnecessary complexity (current vuex with mappers, inject, getters). Or computed prop and using 'as' typed interface. Store in root has sense if your application workflow often hangs on it - f.e. country location as enum in vuex - different logic decisions in same component method etc.