Store with Dependency Injection in Vue 2 with Inversify

Photo by Lucas Hoang on Unsplash

Store with Dependency Injection in Vue 2 with Inversify

An use case for Inversify as an alternative for Vuex

Here at Mònade we are always open to new creative solutions to improve our workflow and our code. Our current go-to stack is Vue 2 with Typescript, which should point naturally to Vuex as a default reactive store. But Vuex has its drawbacks: its complicated, and it has an horrible way to commit actions and mutations (by name, (like php?!))…

In this scenario we came in contact with Inversify, a nifty library we are using to fade out mixins as "place where api calls are made". Some time ago we realised that the same library could be also used to inject singletons inside vue components.

The case

While scaffolding a new view, a fairly complex one, in a company project, a very complex one, we had to get smart about the business logic of the view: long story short, the view has a big data table showing different records populated from a backend API, and various filters scattered in split panes, modals and whatnot. It seemed a clear case of moving the logic inside the store:

  • the data table watches a getter on the store that returns Model[];
  • the various filters update the FilterObject stored inside the store;
  • the store updates its internal state calling the API with some filter parameters every time the FilterObject is updated.

This way the getter returns always a filtered Model and everything is totally decoupled, to allow designers to tinker with the UI without making us dev really unhappy moving around events, wrappers and whatnot.

The store

Having some previous knowledge about Inversify container and injections it came to us that it would be much easier to implement and use as a store a singleton service.

The idea is simple: the store is a Typescript class which extends Vue (so it is technically a Vue component, more on this later). The store is bound to the IOC container in singleton scope. The store is injected inside every component which updates the FilterObject or displays the Model[]. It looks like this:

// store.ts

import { inject, injectable } from "inversify-props";
import { IAnotherService } from "@/services/AnotherService";
import { SYMBOLS } from "@/app.container";

export interface IStore extends Vue {
  get items(): Model[];

  set items(items: Model[]);

  get filters(): FilterObject;

  set filters(filters: FilterObject);

}

@injectable() // Inversify here
export default Store extends Vue implements IStore {
  _items: Model[] = [];
  _filters: FilterObject = {};

  private anotherService!: IAnotherService;

  constructor( // Constructor injection with Inversify
    @inject(SYMBOLS.AnotherService) anotherService: IAnotherService
  ) {
    this.anotherService = anotherService;
  }

  set items(items: Model[]) {
    this._items = items;
    this.$emit('items-updated', this._items); // Vue!
  }

  get items(): Model[] {
    return this._items;
  }

  get filters() {
    return this._filters;
  }

  set filters(filters: FilterObject) {
    this._filters = filters;
    this.$emit('filters-updated', this._filters); // More Vue!

    this.updateItems();
  }

  private async updateItems() {
    this.items = await this.anotherService.findSomeModels(this.filters);
  }

  // ...
}

Glossing over missing type declarations its working are quite simple: we leverage Typescript getters and setters to react to attribute updates: every time a the filter is updated we advertise the filter update event and then call the API (hopefully with a debouncer). The API call will update the items attribute and advertise the fact that the items may have changed.

Extending Vue (i.e. declaring the store as a Vue component) allows us to leverage Vue events to advertise stuff! Event bus baby! (line 31)

Now we just need to:

  1. singleton this class with more Inversify
  2. subscribe to relevant events from the view components.

The container

Now we need to bind our shiny new Store to the container. Here we encountered some roadblocks, but it turns out it was a bad written guide!

In the main.ts where we declare and mount the Vue instance we must initialise the dependency container. Not that reflect-metadata is imported before everything else. Weird errors could be thrown otherwise.

// main.ts

import "reflect-metadata";
// ...
import buildDependencyContainer from "@/app.container";

// ...

buildDependencyContainer();

// ...

new Vue({}).$mount("#app");

not before building the container:

// app.container.ts

import { container } from "inversify-props";

export const SYMBOLS = {
  AnotherService: Symbol.for("IAnotherService"),
  Store: Symbol.for("IStore"),
};

export default function buildDependencyContainer(): void {
  container
    .bind<IAnotherService>(SYMBOLS.AnotherService)
    .to(AnotherService)
    .inTransientScope();
  container
    .bind<IStore>(SYMBOLS.Store)
    .to(Store)
    .inSingletonScope(); // Singleton is the keyword here
}

This functions makes a bit clearer the @inject syntax found in the Store.ts. We will anyway see more on that just now. Note that the Store is declared in singleton scope: this means that it will be instantiated just once. Everyone asking for the Store object anywhere in the single page application will get the same instance, allowing us to use it as a store!

The components

Now that we have a IOC container and some injectable stuff fired up and running we can do fancy stuff without anything more than an @inject decorator. For example, a component updating the filter could be:

// SearchBox.vue
<template>
  <div>
    <input type="text" v-model="term" />
  <div>
</template>
<script lang="ts">
import { Vue, Component, Watch } from "vue-property-decorators";
import { inject } from "inversify-props";
import { SYMBOLS } from "@/app.container";
import { IStore } from "@/services/Store";
@Component({})
export default class SearchBox extends Vue {
  @inject(SYMBOLS.Store) readonly store!: IStore;

  term = "";

  mounted() {
    this.updateTerm();
    this.store.$on('filter-updated', this.updateTerm);
  }

  updateTerm() {
    if (this.store?.filter?.term != this.term) {
      this.term = this.store?.filter?.term ?? "";
    }
  }

  @Watch("term")
  onTermUpdated() {
    this.store.filters = Object.assign(
        {},
        this.store.filters,
        { term: this.term }
     );
  }
}
</script>

Vue on and off methods can be used to hook into the store events. This SearchBox components can be dropped anywhere on a view and it will always be synchronised with the store singleton. Note that the store is injected with a decorator, it is possible to manually get it from the container by calling container.get inside mounted. I find decorators much more elegant and concise. On the other side, with some imagination we can image this component dynamically refreshing its contents as the store updates its state:

// DataDisplay.vue
<template>
  <div>
    <span
      v-for="item in items"
    >{{ item }}<
    /span>
  <div>
</template>
<script lang="ts">
import { Vue, Component } from "vue-property-decorators";
import { inject } from "inversify-props";
import { SYMBOLS } from "@/app.container";
import { IStore } from "@/services/Store";
@Component({})
export default class DataDisplay extends Vue {
  @inject(SYMBOLS.Store) readonly store!: IStore;

  items: any[] = [];

  mounted() {
    this.updateTerm();
    this.store.$on('items-updated', this.updateItems);
  }

  updateTerm(items: any[]) { // Note that being a Vue event there's a payload
    this.items = items;
  }
}
</script>

Even more quick and easy. Just subscribe to the items-updated event and the component will (not so) magically be synchronised with the search box.

Conclusions

Obviously this is a very simple example, but it should satisfy the need to se a complete example of Inversify DI in actions with prop injection in class components.

The store could contain more logic and even actions (as simple methods), even if we prefer to keep inside it the logic strictly related to items searching and filtering. Adding stuff to it just because it’s easy and it works can cause “big store syndrome”.

There are obviously limitations: for one any persistence must be done by hand. We did not yet encounter this requirement, as this is mostly a runtime support for a view. Also, no mutations mean no history and rollbacks, so this is not the thing you are looking for if you need an action history.