Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposal for Vuex 5 #1763

Closed
paleo opened this issue May 26, 2020 · 9 comments
Closed

Proposal for Vuex 5 #1763

paleo opened this issue May 26, 2020 · 9 comments

Comments

@paleo
Copy link

paleo commented May 26, 2020

What problem does this feature solve?

  1. It would be convenient if, from the outside, the difference between getters and state's properties were not visible. Getters should act as state properties in the same way that views act as read-only tables in RDBMS.
  2. It would be nice if part of a store, including a state and its mutations, could be provided to components as a standalone object, in the same way that we provide an object with methods in OOP programming.
  3. Accessing the root store from a Vuex module is a kind of recursive programming, and recursive programming is bad. Additionally, "namespaced modules" are a boring non-JS way to think.
  4. We need type inference with TypeScript.
  5. After trying the composition API, we love composing, and we want to compose.

What does the proposed API look like?

Notice: I haven't implemented a prototype but I have implemented the types of a prototype. I am therefore able to guarantee that all the ideas below are understandable by TypeScript and can produce fully typed stores.

Solution to 1: Vuex 3 state is replaced by innerState, then now state means something else

Here is a first store using a brand new createStore API:

const category1 = createStore(() => {
  const innerState = {
    id: "1",
    name: "Flowers"
  }

  const getters = {
    double: () => innerState.name + innerState.name,
  }

  const mutations = {
    SET_NAME(name) {
      innerState.name = name
    },
  }

  return {
    innerState,
    getters,
    mutations,
  }
})

The createStore API takes a builder function as a parameter, which takes no parameter and returns the implementation of a store object. After this function is called by createStore, a store object is generated from the implementation.

In our store, the state property is now a read-only object composed by innerState and getters:

console.log(category1.state)
// {
//   id: "1"
//   name: "Flowers"
//   double: "FlowersFlowers"
// }

category1.commit.SET_NAME("New name")

console.log(category1.state)
// {
//   id: "1"
//   name: "New name"
//   double: "New nameNew name"
// }

category1.state.name = "Other name" // ERROR: readonly property 'name'

Solution to 2: Multiple stores can be easily instantiated using a store builder

If multiple stores need to be created from the same model, then use createStoreBuilder. With createStoreBuilder, the inner builder function can take any parameters. Here is an example:

export interface CategoryPayload {
  id: string
  name: string
}

export const categoryBuilder = createStoreBuilder((payload: CategoryPayload) => {
  const innerState = payload

  const getters = {
    double: () => innerState.name + innerState.name,
  }

  const mutations = {
    SET_NAME(name: string) {
      innerState.name = name
    },
  }

  return {
    innerState,
    getters,
    mutations,
  }
})

Now, here is how to create the same category1 as above:

const category1 = categoryBuilder({
  id: "1",
  name: "Flowers"
})

The generated builder function simply passes all its parameters to our inner builder function.

Solution to 3: Modules and Root Store are replaced by References to Stores

A store is no longer a tree but a node in a network. The parent-child relationship is replaced by references: each store can be referenced by zero or several stores.

For example, here is how to create a store builder for blog posts, with a reference to a category store:

export interface PostPayload {
  id: string
  title: string
  body?: string
  categoryId?: string
}

const postBuilder = createStoreBuilder((payload: PostPayload, category?: CategoryStore) => {
  const innerState = payload
  const references = { category }

  return {
    innerState,
    getters: {
      fullTitle: () => `${innerState.title} - ${references.category?.state.name}`
    },
    mutations: {
      SET_CATEGORY(category: CategoryStore) {
        innerState.categoryId = category.state.id
        references.category = category
      },
      SET_BODY(body: string) {
        payload.body = body
      },
    },
    references
  }
})

Then, here is how to create a store named post1 that has a reference to category1:

const post1 = postBuilder(
  {
    id: "1",
    title: "Post #1",
    categoryId: "1"
  },
  category1
)

In the store post1, the reference to category1 is directly accessible as a read-only property category:

console.log(post1.category?.state) // Show the content of the category1 state

const category2 = categoryBuilder(/* ... */)
post1.category = category2 // ERROR: readonly property 'category'

post1.commit.SET_CATEGORY(category2)

console.log(post1.category?.state) // Show the content of the category2 state

Solution to 3 (bis): References can be single stores, but also: lists, maps, and sets of stores

To illustrate that, let's create a single store globalData that references all the existing categories in a Map:

export const globalData = createStore(() => {
  const categories = new Map([
    {
      id: "1",
      name: "Flowers"
    },
    {
      id: "2",
      name: "Animals"
    },
  ].map(item => ([item.id, categoryBuilder(item)])))

  const mutations = {
    ADD_CATEGORY(payload: CategoryPayload) {
      categories.set(payload.id, categoryBuilder(payload))
    },
  }

  return {
    mutations,
    references: { categories },
  }
})

Then, our previous postBuilder can be improved using globalData and payload.categoryId to get rid of its second parameter:

const postBuilder = createStoreBuilder((payload: PostPayload) => {
  const innerState = payload

  const references = {
    category: payload.categoryId !== undefined
      ? globalData.categories.get(payload.categoryId)
      : undefined
  }

  // … Same implementation as before …
}

Note: A Map, Set or Array of references that is exposed by a store, is in fact a ReadonlyMap, a ReadonlySet or a readonly array[]. It shouldn't be possible to mutate these collections from outside the store implementation. Only store's mutations can mutate the collections in references.

Solution to 4: Types with TypeScript

Typing is provided by inference:

export type CategoryStore = ReturnType<typeof categoryBuilder>
export type PostStore = ReturnType<typeof postBuilder>
export type GlobalDataStore = typeof globalData

Mutations are called and typed in the same way as direct-vuex:

category1.commit.SET_NAME("New name")

Typing can be tested by cloning this repo.

Solution to 5: The type of the implementation object

As you may have noticed, the build function passed to createStore or createStoreBuilder must return an implementation object that follows a determined structure. Here is this structure in TypeScript syntax:

interface StoreImplementation {
  innerState?: object
  getters?: { [name: string]: () => any }
  mutations?: { [name: string]: (payload?: any) => void }
  references?: { [name: string]: AnyStore | Array<AnyStore> | Map<any, AnyStore> | Set<AnyStore> | undefined }
}

The properties are all optional. The properties getters, mutations, references are dictionary (key-value) objects.

Other points to discuss

Actions are moved out of the store

I suggest removing actions from Vuex because they don't need to have access to the store's internal implementation. By convention, we can call action a function that takes a store as the first parameter, and an optional payload as the second parameter.

export async function savePostBody({ commit, state }: PostStore, body: string) {
  await httpSendSomewhere({ id: state.id, body })
  commit.SET_BODY(body)
}

It is then not necessary to have a dispatch API. Just call the function using the correct store as a parameter when you need it.

API that are attached to the store, like watch

I suggest that additional API that have to be attached to a store, can be grouped in a st property (store tools). Then, a store has the following structure:

{
  ... readonly references to other stores ...
  state: {
    ... readonly innerState's properties ...
    ... getters ...
  },
  commit: {
    ... mutations ...
  },
  st: {
    watch: // Here an attached API provided by Vuex
  },
  readonly: // see the next section
}

In the types of my proposal, I currently provide the typing of a watch object that contains a way to subscribe to each mutation. Maybe not useful.

A read-only version of the store

I suggest that each store provides a readonly property, which contains a version of the same store but without the ability to commit. Its structure could be:

{
  ... readonly references to the 'readonly' property of stores ...
  state: {
    ... readonly innerState's properties ...
    ... getters ...
  }
}

But what if I need the root state somewhere?

No, you don't need the whole state of your application. When you need to access the state of a particular store, just reference that store from your store.

Memory and performance

An implementation with a builder function has a trade-off: it doesn't allow to use JS prototypes, and each function (mutation, getter) has to be created for each instance of a store. I guess there is a similar issue with the Vue composition API…

@kiaking
Copy link
Member

kiaking commented May 28, 2020

Thank you so much for the proposal. We'll definitely reference this one in designing Vuex 5 👍

@ElMassimo
Copy link

References to stores sounds very close to what vuex-stores tries to achieve for the consumer.

I like this proposal, it addresses some of the current pain points in Vuex 👍

@ishowman
Copy link

Vuex won't use ts to rewrite before Vuex 5?

@kiaking
Copy link
Member

kiaking commented Jun 17, 2020

@ishowman Correct. We're not planning to rewrite the code base in TS for Vuex 3 or 4.

@zdm
Copy link

zdm commented Dec 14, 2020

Why not use ES classes and all advantages of OOP for store?

I created wrapper, which converts ES instance to vuex store. It is simple and provides clear interface for users.

Example:

import Vue from "vue";
import Store from "@softvisio/vuex";

class Module1 extends Store {
// .... module class members, see below for example....
}

class MainStore extends Store {
  state1 = 1;
  state2 = 2;

  module1 = Module1;

  get getter1 () {
    return this.state1 + this.state2;
  }

  async action1 () {
     return this.getter1 * 2;
  }
}

const store = Store.buildStore(MainStore);
Vue.prototype.$store = store;
  • declared properties becomes vuex state reactive props, this.state1 - get property, this.state1 = 123 - commit;
  • get - becomes reactive getter with caching;
  • methods - becomes actions;
  • property, which has initial value instance of Store - becomes module;
  • in module you can use .$root and .$parent to access root and parent stores;

Example, how to access store from vue class method:

// commit state2 property
this.$store.state2 = 123;

I think this is more better design, than use getters/commit/dispatch. You can use inheritance and other OOP features.

Source code is simple, you can inspect @softvisio/vuex for details.

@paleo
Copy link
Author

paleo commented Dec 30, 2020

Another proposal here: https://github.com/posva/pinia

@paleo
Copy link
Author

paleo commented Jan 14, 2021

Fyi.
https://www.smashingmagazine.com/2020/12/vuex-library/
https://www.youtube.com/watch?v=dQ5btkDrdJ8

I like that. :-)

But, what about using a collection of stores in a store? @kiaking

@rynz
Copy link

rynz commented Mar 21, 2021

vuejs/rfcs#271

@kiaking
Copy link
Member

kiaking commented Mar 25, 2021

So yes, Vuex 5 RFC is open. Lets continue the discussions over there 👍

@kiaking kiaking closed this as completed Mar 25, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants