Description
What problem does this feature solve?
- 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.
- 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.
- 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.
- We need type inference with TypeScript.
- 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…