Vue 3 custom store without Vuex

Oleksii Shaposhnikov
5 min readNov 8, 2020

EDIT: this approach has become a bit deprecated in 2021. This article represents the way that is very similar to Vuex syntax but built with native Vue 3 Composition API. According to the new official recommendations, Pinia should be the default state management tool in Vue 3 apps that is a good replacement for Vuex.
You can still read this article in order to get acquainted with the alternative way of store management in the Vue 3 app
πŸ˜„

Hello everyone! In this article, I am going to share with you my approach for creating and managing a global store in Vue 3 without Vuex.

Required skills: Vue, JavaScript, TypeScript

Advantages:
1. No Vuex (extra dependency)
2. Compatible with a new Composition API
3. Similar to Vuex syntax, so you don't need to get used to something newπŸ˜„
5. Typed

Let's start.

@/store/index.ts

import auth from "./modules/auth";import { Store } from "./types";const store: Store = { modules: { auth } };export function commit<T>(
moduleName: string,
mutation: string,
payload?: T
) {
const foundModule = store.modules![moduleName];
const foundMutation = foundModule.mutations![mutation]; if (!foundModule) {
console.error(`Store module ${moduleName} not found.`);
return;
}
if (!foundMutation) {
console.error(
`Mutation ${mutation} not found in module ${moduleName}`
);
return;
}
foundMutation(payload);
}
export function dispatch<T>(
moduleName: string,
action: string,
payload?: T
) {
const foundModule = store.modules![moduleName];
const foundAction = foundModule.actions![action];
if (!foundModule) {
console.error(`Store module ${moduleName} not found.`);
return;
}
if (!foundAction) {
console.error(
`Mutation ${action} not found in module ${moduleName}`
);
return;
}
foundAction(payload);
}
export default store;

@/store/types/index.ts

export type StoreModule<S = Record<string, any>> = {
actions?: Record<string, (payload?: any) => any>;
mutations?: Record<string, (payload?: any) => any>;
state?: S;
};
export interface Store {
state?: Record<string, any>;
modules: Record<string, StoreModule>;
}

Let's figure out what's happening here.
We defined a store object with modules inside. That's the initial structure of our store. After that we added two functions: commit and dispatch that perform the same as in Vuex. Inside of those functions we try to find an action or a mutation and call them with a payload.

Providing a store to our app in main.ts:

import store from "./store";
import { createApp } from "vue";
import App from "./App.vue";
const app = createApp(App);
app.provide("store", store);
app.mount("#app");

Let's take a look at @/store/modules/auth/index.ts

import actions from "./actions";
import mutations from "./mutations";
import { mapMutations, mapActions } from "@/store/utils/helpers";
import { reactive, readonly } from "vue";
import { StoreModule } from "@/store/types";
export interface AuthModuleState {}
const state = reactive<AuthModuleState>({});
const authModule: StoreModule<AuthModuleState> = {
state: readonly(state),
mutations: mapMutations<AuthModuleState>(state, mutations),
actions: mapActions<AuthModuleState>("auth", state, actions),
};
export default authModule;

The main thing here is functions mapActionsand mapMutations . They are a bit different from Vuex functionality. Let's see what they do:

@/store/utils/helpers:

import { commit, dispatch } from "@/store";
import { readonly } from "vue";
import { StoreAction, StoreMutation } from "../types";
export const moduleCommit = (moduleName: string) => (
mutation: string,
payload?: any
) => commit<any>(moduleName, mutation, payload);
export const moduleDispatch = (moduleName: string) => (
action: string,
payload?: any
) => dispatch<any>(moduleName, action, payload);
export const mapActions = <S extends Record<string, any>>(
moduleName: string,
state: S,
actions: Record<string, StoreAction<S>>
) => {
const storeActions:
| Record<string, (payload?: any) => any>
| Record<string, any> = {};
for (const key in actions) {
storeActions[key] = (payload?: any) =>
actions[key]({
state: readonly(state),
commit: moduleCommit(moduleName),
dispatch: moduleDispatch(moduleName),
},
payload
);
}
return storeActions as Record<string, (payload?: any) => any>;
};
export const mapMutations = <S extends Record<string, any>>(
state: S,
mutations: Record<string, StoreMutation<S>>
) => {
const storeMutations:
| Record<string, (payload?: any) => any>
| Record<string, any> = {};
for (const key in mutations) {
storeMutations[key] = (payload?: any) =>
mutations[key](state, payload);
}
return storeMutations as Record<string, (payload?: any) => any>;
};

mapActions takes a module name, state, and action objects and returns a new object β€” storeActions, with functions that return an action call with passed store data. mapMutations does basically the same. Note, that a state is passed in actions with readonly that will keep our state safe in actions.

Now we understand how it basically works. The last thing we need to see is the content of @/store/modules/auth/actions.ts and @/store/modules/auth/mutations.ts

Mutations:

import { StoreMutationTree } from "@/store/types";
import { AuthModuleState } from ".";
export interface AuthModuleMutations {
removeUser: () => void;
}
const mutations: StoreMutationTree<AuthModuleState> = {
removeUser(state) {
state.user = getInitialState();
},
};
export default mutations;

Now we see that it is a simple object that will be mapped by mapMutations that we created in the module definition file.

Actions:

import { AuthModuleState } from ".";
import { StoreActionTree } from "@/store/types";
export interface AuthModuleActions {
logout: () => void;
}
const actions: StoreActionTree<AuthModuleState> = {
logout({ commit }) {
commit("removeUser");
},
};
export default actions;

I promised that the structure will be very similar to Vuex πŸ˜ƒ Now you also see that it is a simple object that will be later mapped by our mapActions .

Full @/store/types/index.ts :

import { DeepReadonly, UnwrapRef, Ref } from "vue";export type StoreModule<S = Record<string, any>> = {
actions?: Record<string, (payload?: any) => any>;
mutations?: Record<string, (payload?: any) => any>;
state?: S;
};
export interface Store {
state?: Record<string, any>;
modules: Record<string, StoreModule>;
}
export type StoreActionDataArgument<S> = {
commit: (mutation: string, payload?: any) => void;
dispatch: (action: string, payload?: any) => void;
state: DeepReadonly<S extends Ref ? S : UnwrapRef<S>>;
};
export type StoreActionTree<S> = Record<string, StoreAction<S>>;export type StoreMutationTree<S> = Record<string, StoreMutation<S>>;export type StoreMutation<S, T = any> = (state: S, payload?: any) => T;export type StoreAction<S, T = any> = (
storeArgument: StoreActionDataArgument<S>,
payload?: any
) => T;

That's all about a store structure. In brief:
1. We created an index.ts file where a store object was defined. We included modules.
2. Module related actions and mutations are passing through our mapActions and mapMutations how it is coded in @/store/modules/auth/index.ts

I think there is no need to describe a getters implementation algorithm. You can do it on your own 😜

Composition API

@/store/utils/composables.ts

import { inject, toRefs } from "vue";
import { Store } from "../types";
export const useState = <T extends object>(moduleName: string) => {
const store = inject("store") as Store;
return toRefs<T>(store?.modules[moduleName]?.state as T);
};
export const useMutations = <T extends object>(moduleName: string) => {
const store = inject("store") as Store;
return store?.modules[moduleName]?.mutations as T;
};
export const useActions = <T extends object>(moduleName: string) => {
const store = inject("store") as Store;
return store?.modules[moduleName]?.actions as T;
};

Usage in a component:

import { useState, useActions, useMutations } from "@/store/utils/composables";import { defineComponent } from "vue";export default defineComponent({ setup() {  const { prop } = useState<SomeModuleStateIterface>('moduleName')  const { changeProp } = useMutations<SomeModuleMutationsInterface>   ('moduleName')  const { fetchData } = useActions<SomeModuleActionsInterface>('moduleName')
}
})

Don't forget to pass generic types from your store module in those composables, it will make life easier πŸ˜„

That's all. I believe you will find something interesting for yourself and maybe get new ideas πŸ‘€

--

--