_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._
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'
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.
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
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.
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.
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.
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.
watchI 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.
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 ...
}
}
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.
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…
Thank you so much for the proposal. We'll definitely reference this one in designing Vuex 5 👍
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 👍
Vuex won't use ts to rewrite before Vuex 5?
@ishowman Correct. We're not planning to rewrite the code base in TS for Vuex 3 or 4.
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;
this.state1 - get property, this.state1 = 123 - commit;.$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.
V5 reference
vuex.ts
import { App, inject, InjectionKey, reactive, readonly, isReactive } from 'vue';
type Getter<S> = (state: S) => unknown;
type Action<S> = (state: S, payload?: any) => void;
export interface GetterTree<S> {
[key: string]: Getter<S>;
}
export interface ActionTree<S> {
[key: string]: Action<S>;
}
export interface Persist {
name?: string;
storage?: Storage;
}
export interface StoreOptions<S> {
namespace: InjectionKey<Store<S>>;
state: S;
actions?: ActionTree<S>;
getters?: GetterTree<S>;
persist?: Persist;
}
function isProxy<S>(state: S): void {
const flag = isReactive(state);
if (flag) console.warn("don't use reactive,because it is reactive!");
}
function initialState<S>(state: S): S {
isProxy(state);
return reactive(new Object(state)) as S;
}
function copyState<S>(state: S): S {
return Object.assign({}, state);
}
function initialPersist(persist?: Persist): Persist | undefined {
if (persist) {
if (persist.name && !persist.storage) {
persist.storage = window.localStorage;
return persist;
}
}
}
export class Store<S> {
state: S;
readonly sourceState: S;
readonly actions?: ActionTree<S>;
readonly getters?: GetterTree<S>;
readonly persist?: Persist;
readonly namespace: InjectionKey<Store<S>>;
constructor(store: StoreOptions<S>) {
this.namespace = store.namespace;
this.state = initialState(store.state);
this.sourceState = copyState(store.state);
this.persist = initialPersist(store.persist);
this.getters = store.getters;
this.actions = store.actions;
}
install(app: App): void {
app.provide(this.namespace, this);
}
}
export function createStore<S>(store: StoreOptions<S>): Store<S> {
return new Store(store);
}
export function createNamespace<S>(): InjectionKey<Store<S>> {
return Symbol('vuex');
}
export interface StoreAppOptions {
stores: Store<any>[];
}
export class StoreApp {
readonly stores: Store<any>[];
constructor(stores: StoreAppOptions) {
this.stores = stores.stores;
}
install(app: App): void {
this.stores.forEach((store) => store.install(app));
}
}
export function createStoreApp(app: StoreAppOptions): StoreApp {
return new StoreApp(app);
}
function createPersist<S>(store: Store<S>): void {
if (store.persist) {
const persist = store.persist as Required<Persist>;
const persistData = persist.storage.getItem(persist.name);
if (persistData) {
try {
store.state = reactive(JSON.parse(persistData));
} catch (e) {
const message = `${persist.name} json conversion exception:${e}`;
console.error(message);
}
} else persist.storage.setItem(persist.name, JSON.stringify(store.state));
}
}
function updatePersist<S>(store: Store<S>): void {
if (store.persist) {
const persist = store.persist as Required<Persist>;
persist.storage.setItem(persist.name, JSON.stringify(store.state));
}
}
export class UserStore<S> {
private readonly store: Store<S>;
constructor(store: Store<S>) {
this.store = store;
}
useState(): S {
return readonly(new Object(this.store.state)) as S;
}
useActions(method: string, payload?: unknown): void {
const actions = this.store.actions;
if (actions && actions[method]) {
actions[method](this.store.state, payload);
updatePersist(this.store);
} else console.warn('actions is undefined!');
}
useGetters<T>(method: string): T | undefined {
const getters = this.store.getters;
if (getters && getters[method]) {
return getters[method](this.useState()) as T | undefined;
} else console.warn('getters is undefined!');
}
useReset(): void {
this.store.state = reactive(new Object(this.store.sourceState)) as S;
}
}
export function useStore<S>(store: InjectionKey<Store<S>>): UserStore<S> {
const injectStore = inject(store);
if (injectStore) {
createPersist(injectStore);
} else console.warn('store is undefined!');
return new UserStore(injectStore as Store<S>);
}
Use
store/count.ts
import { createStore, createNamespace } from 'vuex';
export const COUNT_STORE = createNamespace<typeof state>();
export default createStore({
namespace: COUNT_STORE,
persist: { name: 'count' },
state,
actions,
});
store/index.ts
import count from './count';
import { createStoreApp } from 'vuex';
const store = createStoreApp({
stores: [count],
});
export default store;
main.ts
import App from './App.vue';
import store from './store';
createApp(App)
.use(store)
.mount('#app');
import { useStore } from 'vuex';
import {COUNT_STORE } from './count';
const store=useStore(COUNT_STORE);
store.useState();
store.useGetters();
store.useActions();
...
Another proposal here: https://github.com/posva/pinia
Most helpful comment
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:
this.state1- get property,this.state1 = 123- commit;.$rootand.$parentto access root and parent stores;Example, how to access store from vue class method:
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/vuexfor details.