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 found 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 a 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 a 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 👀

Front-end Vue developer