loading...

Integrating Vuex with the Vue Composition API

chiborg profile image Gabriel Birke ・6 min read

This post was originally published on my blog

This post shows my step-by-step journey using the Vue composition API in a Vue project that uses Vuex. I started with the question "How can you gradually introduce the composition API without having to rewrite everything?"

You can find the complete source code on GitHub

Starting application

For my exploration, I started out with a minimalistic application that uses Vuex: A button that toggles the state of "Awesomeness" on and off.

<template>
  <div>
    <p>
      Awesomeness is <strong>{{ awesomeness }}</strong>
    </p>
    <p><button v-on:click="toggle">Toggle</button></p>
  </div>
</template>

<script>
import { mapState } from "vuex";

export default {
  computed: mapState(["awesomeness"]),
  methods: {
    toggle() {
      this.$store.commit("toggleAwesomeness");
    }
  }
};
</script>

The store looks like this:

import Vue from "vue";
import Vuex from "vuex";

Vue.use(Vuex);

export default new Vuex.Store({
  state: {
    awesomeness: "On"
  },
  mutations: {
    toggleAwesomeness(state) {
      if (state.awesomeness === "On") {
        state.awesomeness = "Off";
        return;
      }
      state.awesomeness = "On";
    }
  },
  actions: {},
  modules: {}
});

The file main.js imports the store and passes it to the root component:

import Vue from "vue";
import App from "./App.vue";
import store from "./store";
import CompositionAPI from "@vue/composition-api";

Vue.config.productionTip = false;
Vue.use(CompositionAPI);

new Vue({
  store,
  render: h => h(App)
}).$mount("#app");

Side note: With only one stateful component, using Vuex is
over-engineering since the main purpose of Vuex is to share state between components. But I want to expand my example to show how components with and without and composition API can share the same store, so bear with me.

First attempt - getting the store from the root element

In the setup method of the component API you don't have a reference to the Vue component instance, you can't call this.$store. There is a workaround, though: The setup method has a second parameter, context, that allows you to access the root instance of the Vue component tree. If that root instance has a store (because the application initialization code called Vue.use(Vuex)), then you can access that store instance. The component looks like this:

<template>
  <div>
    <p>
      Awesomeness is <strong>{{ awesomeness }}</strong>
    </p>
    <p><button v-on:click="toggle">Toggle</button></p>
  </div>
</template>

<script>
import { computed } from "@vue/composition-api";

export default {
  setup(_, ctx) {
    const store = ctx.root.$store;
    const awesomeness = computed(() => store.state.awesomeness);
    const toggle = () => store.commit("toggleAwesomeness");
    return { awesomeness, toggle };
  }
};

You can see that the component has more lines than the component without the composition API. This is because it doesn't do what the composition API is good at - encapsulating behavior in separate modules, independent from the Vue code and re-using it in different components. My example repository shows how to put the behavior in a separate module, but for reading the examples side by side I chose this structure.

The unit test for this component needs one more line than the test for the "classic" unit test - adding the composition API wrapper plugin to the local Vue instance. With Vue 3, even that line will become unnecessary.

With the composition API there is no need for the mapState,
mapGetters or other Vuex helpers. Instead, you write small, one-line wrapper functions yourself. This gives you much more fine-grained control over which parts of the store you connect to the component. Some people may see that implicitness as boilerplate and adding more lines to
the code, but that's for you to decide.

Second attempt - using Node module singleton pattern

I admit that I prefer to write object-oriented backend code, using PHP with dependency injection and implicit instantiation. I still have to get used to the idea, that whenever a Node module instantiates a class and exports it -
like in our example store above - that same instance will get re-used whenever you import it. In our case, it allows us to write the component like this:

<template>
  <div>
    <p>
      Awesomeness is <strong>{{ awesomeness }}</strong>
    </p>
    <p><button v-on:click="toggle">Toggle</button></p>
  </div>
</template>

<script>
import store from "../../store";
import { computed } from "@vue/composition-api";

export default {
  setup() {
    const awesomeness = computed(() => store.state.awesomeness);
    const toggle = () => store.commit("toggleAwesomeness");
    return { awesomeness, toggle };
  }
};

The store, directly imported into the component, refers to the same store that I imported in my main.js file.

What looks straightforward and concise, becomes a nightmare to test: In the unit tests, you have to mock the imported store module. All my attempts to use Jest.mock failed. I think it's an issue of circular dependencies: mocking a module means you have to import it at the top of the test. But before you can import it, you have to parameterize the returned mock, which is impossible, because the import of the mocked module has to happen before any other code is executed. Maybe some Jest-Gurus can enlighten me with a pull request.

Third attempt - using provide and inject

The composition API has the provide function that delivers an instance to all child components. The modified main.js now looks like this:

import Vue from "vue";
import App from "./App.vue";
import store from "./store";
import { provide }, CompositionAPI from "@vue/composition-api";

Vue.config.productionTip = false;
Vue.use(CompositionAPI);

new Vue({
  setup(){
    provide("vuex-store", store);
  },
  store,
  render: h => h(App)
}).$mount("#app");

Our component can access the store by calling inject in its setup method:

<template>
  <div>
    <p>
      Awesomeness is <strong>{{ awesomeness }}</strong>
    </p>
    <p><button v-on:click="toggle">Toggle</button></p>
  </div>
</template>

<script>
import { computed, inject } from "@vue/composition-api";

export default {
  setup() {
    const store = inject("vuex-store");
    const awesomeness = computed(() => store.state.awesomeness);
    const toggle = () => store.commit("toggleAwesomeness");
    return { awesomeness, toggle };
  }
};

The test can provide the component with its own mock implementation of the store.

In my example repository, I have wrapped the calls to provide and inject in custom function calls to make them more semantic and enable searching for specific usages of the store, in case you use inject for different things. It also encapsulates inject, so you could use a different state management library later.

Replacing Vuex with your own state management

If you want to replace Vuex with your custom state management, I recommend reading the article "State Management with Composition API".

Implementing a custom store that shares its state across all components looks like this:

import Vue from "vue";
import CompositionAPI, { ref } from "@vue/composition-api";

// Boilerplate for Vue 2
Vue.use(CompositionAPI);

// Put inside `useRef` for non-shared state
const awesomeness = ref("On");

export function useAwesomeness() {
  function toggle() {
    if (awesomeness.value === "On") {
      awesomeness.value = "Off";
      return;
    }
    awesomeness.value = "On";
  }

  return {
    awesomeness,
    toggle
  };
}

The component using the store can call useRef directly in setup():

<template>
  <div>
    <p>
      Awesomeness is <strong>{{ awesomeness }}</strong>
    </p>
    <p><button v-on:click="toggle">Toggle</button></p>
  </div>
</template>

<script>
import { useAwesomeness } from "./useAwesomeness";

export default {
  setup() {
    return useAwesomeness();
  }
};

If you are not in a greenfield project and need to synchronize your custom store with Vuex, you need to write a synchronization plugin, that changes your reactive objects on every store mutation and commits new values to the store on every value change, using the watch function from the composition API. There is a danger to trigger an endless loop, so you need to keep track where a change is coming from.
This is how a Vuex plugin could look like:

import { watch } from "@vue/composition-api";
import { useRef } from "./components/composition/useRef";

export function synchronizeAwesomeness(store) {
  const { awesomeness, toggle } = useRef();
  let inWatcher = false;

  store.subscribe(mutation => {
    if (mutation.type === "toggleAwesomeness" && !inWatcher) {
      toggle();
    }
  });

  watch(awesomeness, newAwesomness => {
    if (newAwesomness !== store.state.awesomeness) {
      inWatcher = true;
      store.commit("toggleAwesomeness");
      inWatcher = false;
    }
  });
}

Conclusion

Using provide and inject looks like the most promising migration path - your code stays functional, your component is independent from the root component and you can mock the store in the unit tests. You can gradually migrate your existing components to the composition API or create new
components with the composition API.

If you encapsulate the usage of Vuex behind a function, not using inject directly in your component, (see example repository), you can replace Vuex with a pure composition API state management solution when all your components use the function.

I think the synchronization layer solution is too clever and too error-prone and adds a maintenance burden to all future state changes.

Discussion

pic
Editor guide