Skip to content

Using Vue.js Composition API as Store

Submitted by Andrej Galuf on 01.04.2020.

When we need a Flux-like store in Vue.js, the first thought goes to the official solution, Vuex. It's fairly simple to set up and allows us organize the store into logical, namespaced modules. However, as more and more typescript is being introduced, it's becoming painfully obvious why string-based mappers are a bad idea. For one, typescript cannot provide typings hidden behind the mappers. Solutions such as Direct-Vuex exist precisely to address this problem, but introduce additional complexity to a relatively simple problem - I want to have some place to store reactive information that I need on more than one location.

But with Vue 3.0, Vue is getting the new Composition API. In the words of its authors, the new API was introduced to address the Lack of a clean and cost-free mechanism for extracting and reusing logic between multiple components. One major advantage of the new API is that one can easily extract parts of the component's code out of the component and into separate services, separating business logic from presentation. However, as a side effect, it is actually possible to extract parts of Vue's own code out of the components as well. If we want to initialize a new ref, this ref does not need to be in the setup() method - it can be anywhere, even in a separate file. On top of that, unlike the old Options API, the new Composition API is highly compatible with Typescript.

In a natural extension of this thought, we could store a reactive information outside any component, then load it where it's needed, exactly as Vuex does. Let's try using it instead of Vuex as our store tool!

Suppose our Use Case requires that we set some data in Component A and display it in Component B. Traditionally, we would map a Vuex mutation to a method in Component A and a Vuex getter to a computed property in Component B:

// @/store/modules/clicks.js
const state = {
    clicks: 0
}

const getters = {
    getClickCount: state => state.clicks
}

const mutations = {
    incrementClicks: (state) => { state.clicks += 1 }
}

export default {
    namespaced: true,
    state,
    getters,
    mutations
}
// @store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import clicks from './modules/clicks'

Vue.use(Vuex)

export default new Vuex.Store({
    modules: {
        clicks
    }
})
// @/components/ComponentA.vue
<template>
    <div id="component-a">
        <button @click="incrementClicks()">Set Item</button>
    </div>
</template>

<script>
import { mapMutations } from 'vuex'

export default {
    methods: {
        ...mapMutations({
            incrementClicks: 'clicks/incrementClicks'
        })
    }
}
</script>
// @/components/ComponentB.vue
<template>
    <div id="component-b">
        Clicks: {{ getClickCount }}
    </div>
</template>

<script>
import { mapGetters } from 'vuex'

export default createComponent({
    computed: {
        ...mapGetters({
            getClickCount: 'clicks/getClickCount'
        })
    }
})
</script>

As we can see, we require one method incrementClicks in Component A and one computed property getClickCount in Component B.

Now, obviously, this works just fine. The state in store changes with the click on the button and displays the value in the other component. However, we still haven't done anything to introduce type safety. Worse, once we use the Composition API with typescript, our components will explode, because the property $store isn't declared in the context's interface by default. One solution would be to import the store directly into the component or to use provideStore / useStore helpers, but this introduces other problems. For instance, we have a system where multiple front-end applications are built for different roles with different services and stores available. Importing any specific store directly would mean the component would no longer be usable for the other roles. If we just load the entire store for all, we needlessly introduce overhead for everyone.

So what's the alternative? Let's recreate our store with Composition API and see how that looks like:

// @/services/click-counter.ts
import { ref, computed } from '@vue/composition-api'

const count = ref(0)

export const getClickCount = computed(() => count.value)

export const incrementClicks = () => count.value += 1
// @/components/ComponentA.vue
<template>
    <div id="component-a">
        <button @click="incrementClicks()">Set Item</button>
    </div>
</template>

<script lang="ts">
import { createComponent } from '@vue/composition-api'
import { incrementClicks } from '@/services/click-counter'

export default createComponent({
    setup() {
        return {
            incrementClicks
        }
    }
})
</script>
// @/components/ComponentB.vue
<template>
    <div id="component-b">
        Clicks: {{ getClickCount }}
    </div>
</template>

<script lang="ts">
import { createComponent } from '@vue/composition-api'
import { getClickCount } from '@/services/click-counter'

export default createComponent({
    setup() {
        return {
            getClickCount
        }
    }
})
</script>

And ... well, that's it. Typescript is happy, we can only change the state through the incrementClicks method (mutation) and we can only get the value through the getClickCount computed property (getter). We don't even need to initialize any global store, as the service brings its own store with it. In our multi-app environment, only the components that actually use our new service will also have the required store code, meaning little to no overhead. Another side effect is that the IDE will immediately be able to find usages and be able to help with refactorings, because nothing is hidden behind strings anymore.

In our case, we quickly realized that we don't actually need vuex anymore, as the new pattern is far more elegant and easier to reason with.

Thank you and see you next time :)