A store implementation from scratch using Vue3's Composition API

I've built a store implementation that allows name-spaced actions and helps with the separation of concerns. The new Composition API in Vue3 also allows completely new, convenient ways of using it.


This article is crossposted on dev.to. Feel free to join the discussion there.


At some point I started moving a side project over to Vue3 (which is still in beta). The side project is in a rather early stage and so I decided to rebuild the whole underlying foundation of it from scratch making use of the new possibilities of Vue3, especially of course the composition API.

Nuisance

One nuisance I had was the way I handled state. I didn't use Vuex but instead left state handling to a global state class that I added to Vue like Vue.prototype.$store = new StorageHandler. That allowed me to access global state from everywhere within Vue components via this.$store and worked pretty well in most cases. But when the store grew a bit more complex I wished back some of the features Vuex offers. Especially actions, name-spacing and with them the much better encapsulation of the state. It also adds extra work as soon as you need to access the state from outside Vue, for example in API call logic.

When moving to Vue3 I played with the thought to try Vuex4. It has the same API as Vuex3 and is meant to be usable as a drop-in when updating a Vue2 application to Vue3. But rather quickly I decided to roll my own, simplified implementation that uses the new Composition API because it would make things much neater. But lets quickly recap first what this Composition API is and how it helped me here:

Composition API vs Options API

What is the Composition API and what is the Options API? You might not have heard of those terms yet but they will become more popular within the Vue ecosystem as soon as Vue3 is out of beta.

The Options API is and will be the default way to build components in Vue. It is what we all know. Lets assume the following template:

<div>
  <div class="greeting">{{ hello }}</div>
  <input v-model="name" placeholder="change name" />

  <div class="counter">Clicked {{ clicks }} times</div>
  <button @click="countUp">click!</button>
</div>

This is how an Options API example would look like:

const component = new Vue({
    return {
      name 'World',
      clicks: 0
    }
  },
  computed: {
    hello () {
      return `Hello ${this.name}`
    }
  },
  methods: {
    countUp () {
      this.clicks++
    }
  }
})

This still works the same in Vue3. But additionally it supports a new setup method that runs before initializing all the rest of the component and provides building blocks. Together with new imports this is the Composition API. You can use it side-by-side or exclusively to create your components. In most cased you'll not need it but as soon as you want to reuse logic or simply split a large component into logical chunks, the Composition API comes in very handy.

Here's one way how the example could look like using setup():

import { defineComponent, computed } from 'vue'

// defineComponent() is now used instead of new Vue()
const component = defineComponent({
  setup () {
    // greeting
    const name = ref('World')
    const hello = computed(() => `Hello ${name.value}`)
    // counting
    const clicks = ref(0)
    const countUp = () => clicks.value++

    return { name, hello, clicks, countUp }
  }
}  

Some things here might seem odd. computed gets imported, ref and whyname.value? Isn't that going to be annoying? It would be out of scope for this article, so I better point you to a source that explains all of this much better than I could: composition-api.vuejs.org is the place to go! There are also great courses on VueMastery.

Back to topic: The cool new thing now is that we can group concerns. Instead of putting each puzzle piece somewhere else (that is variables in data, reactive properties in computed and methods in methods) we can create everything grouped next to each other. What makes it even better is that thanks to the global imports, every piece can be split out into separate functions:

// Afraid of becoming React dev? Maybe call it 'hasGreeting' then.
function useGreeting () {
  const name = ref('World')
  const hello = computed(() => `Hello ${name.value}`)
  return { name, hello }
}

function useCounting () {
  const count = ref(0)
  const countUp = () => count.value = count.value + 1
  return { count, countUp }
}

const component = defineComponent({
  setup () {
    const { name, hello } = useGreeting()
    const { count: clicks, countUp } = useCounting()
    return { name, hello, clicks, countUp }
  }
}  

This works the same way and it works with everything, including computed properties, watchers and hooks. It makes it also very clear where everything is coming from, unlike mixins. You can play around with this example in this Code Sandbox I made.

Minimalist but convenient state handling

While looking at the Composition API I thought about how it could be nice for simple and declarative state handling. Assuming I have somehow name-spaced state collections and actions, a bit like we know from Vuex, for example:

import { ref } from 'vue'

// using 'ref' here because we want to return the properties directly
// otherwise 'reactive' could be used
export const state = {
  name: ref('World'),
  clicks: ref(0)
}

export const actions = {
  'name/change': (name, newName) => {
    name.value = newName
  },
  'clicks/countUp': (clicks) => {
    clicks.value++
  }
}

Now this is a very simplified example of course but it should illustrate the idea. This could be used directly and the Composition API makes it not too inconvenient alread. Unfortunately it is not exactly beautiful to write (yet):

import { state, actions } from '@/state'

defineComponent({
  setup () {
    return {
      name: state.name,
      clicks: state.clicks,
      // brrr, not pretty
      changeName (newName) { actions['name/change'](state.name, newName) }
      countUp () { actions['clicks/countUp'](state.clicks) }
    }
  }
})

To make this not only prettier but also less verbose, a helper can be introduced. The goal is to have something like this:

import { useState } from '@/state'

defineComponent({
  setup () {
    const { collection: name, actions: nameActions } = useState('name')
    const { collection: clicks, actions: clickActions } = useState('clicks')

    return {
      name,
      clicks,
      changeName: nameActions.change
      countUp: clickActions.countUp
    }
  }
})

Much nicer! And not too hard to build! Lets have a look at the useState source code:

function useState (prop) {
  // assumes available state object with properties
  // of type Ref, eg const state = { things: ref([]) }
  const collection = state[prop]

  // assumes available stateActions object with properties
  // in the form 'things/add': function(collection, payload)
  const actions = Object.keys(stateActions).reduce((acc, key) => {
    if (key.startsWith(`${prop}/`)) {
      const newKey = key.slice(prop.length + 1) // extracts action name
      acc[newKey] = payload => stateActions[key](collection, payload)
    }
    return acc
  }, {})

  return { collection, actions }
}

Just ten lines and it makes life so much easier! This returns the collection reference and maps all actions accordingly. For the sake of completeness here a full example with state and stateActions:

import { ref } from 'vue'

// not using reactive here to be able to send properties directly
const state = {
  count: ref(0),
  name: ref('World')
}

const stateActions = {

  'count/increase' (countRef) {
    countRef.value++
  },
  'count/decrease' (countRef) {
    countRef.value--
  },

  'name/change' (nameRef, newName) {
    nameRef.value = newName
  }

}

function useState (prop) { /* ... */ }

Now useState('count') would return the reference state.count and an object with the actions increase and decrease:

import { useState } from '@/state'

defineComponent({
  setup () {
    const { collection: count, actions: countActions } = useState('count')
    return {
      count,
      countUp: countActions.increase
    }
  }
})

This works well for me and happened to be very convenient already. Maybe I'll make a package out of it. What are your opinions on this?