Article header

Using Vuex Stores with Composition API in Vue 2.7

Published by Hendry Sadrak
Posted on ,
Updated on

What's up?

I've been a big fan of Vue for some time now. For that reason at Modash we've built frontends with Vue for years. Started from the early 2.x versions.

It has some kind of elegancy with the reactive data engine which I profoundly enjoy.

I've always tried to keep up with the updates. See what's new. I love exploring simpler and more graceful ways to create value through writing business logic.

We just migrated to Vue 2.7 last week. Starting the migration project took some consideration, and planning before we were ready to get started. There were lots of deprecated functionalities to be replaced with doing things correctly.

Some of our setup uses SFCs, some TSX. For our main product there are ~500 .vue files and ~80 .tsx files. Trying out TSX with Vue was fun but the DX is much worse. We consider the TSX setup legacy and will have it replaced over time.

The migration from Vue 2.6 to 2.7 took a couple of days, no biggie. One person, some support from the team, and a PR to be reviewed…

PR diff count
Oneliner changes, migrations for deprecated functionality, yarn.lock… – enjoy 😏

Why even migrate? We did it to be able to use <script setup>. Less boilerplate to manage. More happy-customer business logic written. Less time to review, more time to improve UX.

The Problem

The whole point of the Vue update was to start using <script setup>. For using Vuex in the composition API style Vuex 4 provides the useStore. The API is "great" 😄 if you don't need Typescript types.

<script setup lang="ts">
import { useStore } from 'vuex'

const store = useStore()
</script>

But what about these magic strings all thoughout the codebase?

const someValue = computed(() => store.state.someValue)
const someMutation = () => store.commit('someMutation')
const someAction = () => store.dispatch('someAction')

Want to change actions or mutations names? Good luck! Ugh.

We rely heavily on Vuex modules. Each part of the application has it's own store. For example there are userStore, authStore, and layoutStore. So the example becomes this:

const someValue = computed(() => store.state['namespace']['someValue'])
const someMutation = () => store.commit('namespace/someMutation')
const someAction = () => store.dispatch('namespace/someAction')

Namespace being the name of the module (e.g. user, auth).

We're against double typing. Types should be written once, all the logic depending previous definitions should infer types automatically, without any issues. Nobody wants to search-n-replace in files to update the magic strings and double-typed TS definitions.

Solutions?

Pinia looks awesome! Unfortunetly with the size of our store modules migrating from Vuex to Pinia would take more effort we can spend for a change without any real customer value created. Startup_vibes.

It'll be worth the effort some day. Our take on refactoring is to do it gradually. When a store will have bigger change and the refactor could be weighted in. Till we have these opportunities we'll implement new stores in Pinia and old stores will continue using Vuex.

Other implementations do exist but none of them solve all:

  • Great Typescript support
  • Vuex modules/namespaces
  • Ease of use
  • Composition API
  • Works well with VSCode (jump to definition, peeking, completion)

So… What Do?

In development there's always infinite solutions to infinite problems.
What did we do? Of course wrote a little store definition wrapper and a composable 😏. The solution has all of it: great typescript support, best DX for Vuex modules, easy as fuck to use, and Composition API (bonus: VSCode ❤️).

Here's an example how easy it's to use the implementation. Defining your store module:

src/stores/user.store.ts
export const userStore = createVuexModule('user', {
  state: { email: undefined, loggedIn: false },
  mutations: {
    user: (state, { email, loggedIn }) => {
      state.email = email
      state.loggedIn = loggedIn
    },
  },
  actions: {
    getStatus: async ({ commit }) => commit('user', await fetch()),
  },
  getters: {
    loggedIn: state => state.loggedIn && !!state.email,
  },
})

Looks like a normal Vuex store, right? Just with the extra createVuexModule('user', …). Migration for existing stores? Supersimple!.

- export const userStore: Module<UserStoreState, RootState> = {
-   namespaced: true,
+ export const userStore = createVuexModule('user', {
    state: { email: undefined, loggedIn: false },
    mutations: { … },
    actions: { … },
    getters: { … },
- }
+ })

Finally, using the store module in your setup script:

src/app.vue
<script setup lang="ts">
import { useStore } from '…'
import { userStore } from '@/stores/user.store'

const {
  getters: { loggedIn },
  actions: { getStatus },
} = useStore(userStore)

onMounted(() => {
  getStatus()
})
</script>

<template>
  <div>User is logged in? {{ loggedIn }}</div>
</template>

Result

Here's couple of screenshots from our codebase using this setup. Implemented the wrapper for the teamStore:

Store definition
Store? Defined

Using the teamStore in a components <script setup>, hovering the actions to see what actions are available, with types:

Actions typed
Actions? Typed

Hovering a single destructed action to see it's function definition:

Get team
Usage? Breezy 🔥

Even the JSDoc comment for the original action function is displayed. And as a solid bonus, in VSCode cmd+clicking on the getTeam jumps to the action definition in the teamStore!

Get the Code

I'm planning to create a package based on this and will publish it to NPM. But, in the meantime you can find the code at https://gist.github.com/hendrysadrak/af379c8eeaa9ba377b8be809e1381a80

Credit

Inspired by:

Post creating the wrapper when I was doing research for this article I stumbled upon another kindof elegant solution to the same problem https://github.com/victorgarciaesgi/vuex-typed-modules but it's always up to you which solution is the fix for your problem. ❤️