Skip to content

Proposal for Vuex 5 #1763

Closed
Closed
@paleo

Description

@paleo

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…

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions