Creating and Registering a Vuex Module | Full-Stack Google Contacts Clone with AdonisJS Framework (Node.js) and Quasar Framework (Vue.js)

Creating and Registering a Vuex Module | Full-Stack Google Contacts Clone with AdonisJS Framework (Node.js) and Quasar Framework (Vue.js)

Each frontend component of our Google Contacts Clone app currently consumes the mock contacts data independently. In this lesson, we will begin using Vuex to manage the state of our contacts centrally so that all components can read from the same store. We will also get the Vuex store ready to fetch data from the backend of the application by creating actions. This step is necessary before we hook our frontend to the API server.

This lesson focuses on Vuex. If you do not know what it is, please take some time to study the Vuex docs. I will equally explain the pratical applications of it in this lesson.

Let's start by creating a new branch for our repo:

# Make sure you are within your project
git checkout -b 17-connect-components-to-vuex-store

Create the contacts Vuex Store Module

There is a placeholder Vuex store module called module-example in the ui/src/store directory. This module was automatically created by the Quasar Framework. We will rename that folder to contacts and update the Vuex files within the folder.

In VS Code Explorer, rename module-example directory in ui/src/store to contacts.

Open ui/src/store/contacts/state.ts. Update the file with the content below. Refer to this snapshot for the same content.

import { Contact } from "src/types";

export interface ContactStateInterface {
  contacts: Array<Contact>;
  currentContact: Contact | null;
  totalContacts: number | null;
}

function state(): ContactStateInterface {
  return {
    contacts: [],
    currentContact: null,
    totalContacts: null,
  };
}

export default state;

Above, we are defining the interface for our contacts module state called ContactStateInterface. The interface is implemented by the state function. The state function returns an object containing the contacts array which is an array of all Contacts which will be displayed on the Index.vue page; the currentContact object which will be the current Contact being viewed or edited; and the totalContacts which will be the total number of contacts in our database.

Open ui/src/store/contacts/index.ts. Update all occurrences of ExampleStateInterface to ContactStateInterface. Update all occurrences of exampleModule to contactsModule. This file exports an object containing the actions, getters, state, and mutations for the module. Refer to this snapshot for the updated file.

import { Module } from "vuex";
import { StateInterface } from "../index";
import state, { ContactStateInterface } from "./state";
import actions from "./actions";
import getters from "./getters";
import mutations from "./mutations";

const contactsModule: Module<ContactStateInterface, StateInterface> = {
  namespaced: true,
  actions,
  getters,
  mutations,
  state,
};

export default contactsModule;

Open ui/src/store/contacts/mutations.ts. This file exports an object which contains functions for mutating (changing) the state of our contacts module. The functions are setContactList, setCurrentContact, and setTotalContacts. Update the file with the content below. Refer to this snapshot for the same content.

import { Contact } from "src/types";
import { MutationTree } from "vuex";
import { ContactStateInterface } from "./state";

const mutation: MutationTree<ContactStateInterface> = {
  setContactList: (state, payload: Contact[]) => {
    const contactListLength = state.contacts.length;
    const isContactListEmpty = contactListLength === 0;
    state.contacts.splice(
      isContactListEmpty ? 0 : contactListLength - 1,
      0,
      ...payload
    );
  },
  setCurrentContact: (state, payload: Contact) => {
    state.currentContact = payload;
  },
  setTotalContacts: (state, payload: number) => {
    state.totalContacts = payload;
  },
};

export default mutation;

Each mutation function receives two argument. The first one is always the state of the module, while the second is always the payload sent when the mutation function is called i.e. a commit is made.

The setContactList mutation receives a payload which is an array of Contacts. We use the Array.splice method to insert the contacts at the end of the Contact array. The mutation will be committed during virtual scroll of the table on the Index.vue component. The setCurrentContact mutation sets the payload (a Contact object) to the state.currentContact module state property. The setTotalContacts mutation sets the state.totalContacts module state property.

Open ui/src/store/contacts/getters.ts. This file exports an object which contains functions for getting (retrieving) the state of our contacts module. The functions are contactList, currentContact, and totalContacts. Update the file with the content below. Refer to this snapshot for the same content.

import { GetterTree } from "vuex";
import { StateInterface } from "../index";
import { ContactStateInterface } from "./state";

const getters: GetterTree<ContactStateInterface, StateInterface> = {
  contactList: (state) => state.contacts,
  currentContact: (state) => state.currentContact,
  totalContacts: (state) => state.totalContacts,
};

export default getters;

Open ui/src/store/contacts/actions.ts. This file exports an object which contains asynchronous functions for making requests to our backend server and making commits (changing our module state) when the results from our backend arrive. The functions are LOAD_CURRENT_CONTACT and LOAD_CONTACTS. Update the file with the content below. Refer to this snapshot for the same content.

import { Contact } from "src/types";
import { ActionTree } from "vuex";
import { StateInterface } from "../index";
import { ContactStateInterface } from "./state";
import { contacts as rawContacts } from "../../data/Google_Contacts_Clone_Mock_Data";

const actions: ActionTree<ContactStateInterface, StateInterface> = {
  LOAD_CURRENT_CONTACT({ commit }, id: Contact["id"]): Promise<Contact> {
    return new Promise((resolve, reject) => {
      try {
        const currentContact = rawContacts
          .filter((contact) => contact.id === id)
          .reduce((prev, cur) => {
            prev = { ...cur };
            return prev;
          }, {} as Contact);

        commit("setCurrentContact", currentContact);

        return resolve(currentContact);
      } catch (error) {
        return reject(error);
      }
    });
  },

  LOAD_CONTACTS(
    { commit },
    { nextPage, pageSize }: { nextPage: number; pageSize: number }
  ): Promise<Contact[]> {
    return new Promise((resolve, reject) => {
      try {
        const requestedContacts = [...rawContacts].slice(
          nextPage <= 1 ? 0 : (nextPage - 1) * pageSize,
          nextPage <= 1 ? pageSize : nextPage * pageSize
        );

        commit("setContactList", requestedContacts);
        commit("setTotalContacts", rawContacts.length);

        return resolve(requestedContacts);
      } catch (error) {
        return reject(error);
      }
    });
  },
};

export default actions;

The LOAD_CURRENT_CONTACT action is dispatched when a contact is being viewed or edited. The id of the contact being viewed or edited is dispatched with the action and passed into the action as the second argument after the context which is the first argument. The LOAD_CURRENT_CONTACT action uses the id to filter the requested contact from the Contacts array in the Google_Contacts_Clone_Mock_Data file. The filtered array is then reduced to obtained the requested contact. After getting the requested contact, the setCurrentContact mutation is committed to set the currentContact in the contacts module state.

Note that this line:

const currentContact = rawContacts
          .filter((contact) => contact.id === id)
          .reduce((prev, cur) => {
            prev = { ...cur };
            return prev;
          }, {} as Contact);

Could also be simplified as shown below. But I wanted to demonstrate how to reduce a filtered array result to get an object:

const currentContact = rawContacts
          .filter((contact) => contact.id === id)[0] as Contact;

The LOAD_CONTACTS action is called in the Index.vue component during the virtual scrolling of the table. The action is dispatched with an object containing the nextPage and pageSize and receives same as its second argument. It takes the rawContacts from the Google_Contacts_Clone_Mock_Data file and slices the Contacts array to obtain a range of Contacts depending on the values of the nextPage and pageSize properties.

const requestedContacts = [...rawContacts].slice(
          nextPage <= 1 ? 0 : (nextPage - 1) * pageSize,
          nextPage <= 1 ? pageSize : nextPage * pageSize
        );

After obtaining the range of Contacts, the setContactList mutation is committed to append the contacts into the contacts array in the module state. The setTotalContacts is also called to set the total number of contacts in the Google_Contacts_Clone_Mock_Data file.

You will notice that the actions are promises and each of them resolves a value at the end. These values can be received as results in the components which dispatched the actions. So, you actually decide to get the currentContact from the result of the action dispatch instead of fetching from the Vuex store. The result of a promise is obtained in the then function:

await store
          .dispatch("contacts/LOAD_CURRENT_CONTACT", props.contactId)
          .then((currentContact) => {
           // The `currentContact` above is from the resolved value not the Vuex store
            Object.keys(currentContact).forEach((key) => {
                if (key !== "id") {
                  form[key].value = currentContact[key];
                }
              });
          });

But we are using Vuex store to centrally store our data. So, the same action dispatch looks like this in our code:

await store
          .dispatch("contacts/LOAD_CURRENT_CONTACT", props.contactId)
          .then(() => {
            void nextTick(() => {
              Object.keys(currentContact.value).forEach((key) => {
                if (key !== "id") {
                  form[key].value = currentContact.value[key];
                }
              });
            });
          });

Also note that since the actions are promises, we have to await them when dispatching them in our components as seen in the above examples.

the nextTick hook from vue is used to carry out further processing on the currentContact in the next process tick. This ensures that data committed into the store have been properly propagated to the getters, else we might getter null or old values instead of the expected ones.

Also note that since our actions are under the contacts module, we have to dispatch them by prepending contacts to the name of the action as the type of the dispatch call: dispatch("contacts/LOAD_CURRENT_CONTACT", props.contactId). Action dispatch have the format: dispatch(type, payload)

Registering the contacts modue with Vuex

Now, we will register our contacts modules with the store/index.ts file so that Vuex is aware of the module. Open ui/src/store/index.ts. Refer to this snapshot for the updated file. Copy-and-paste the contents of the snapshot file into the ui/src/store/index.ts file. Let's go through the changes.

At Line 7, we import createLogger from the vuex node module. The createLogger function will be used to log the state of our Vuex store to the console after each action is dispatched and mutations are committed. I used this because of performance issues with the Vuex browser addon when testing the contacts table built with the QTable component.

import {
  createStore,
  Store as VuexStore,
  useStore as vuexUseStore,
+  createLogger,
} from "vuex";

At Lines 10 and 11, we import the contacts module and the ContactStateInterface

+ import contacts from "./contacts";
+ import { ContactStateInterface } from "./contacts/state";

At Line 24, we add the ContactStateInterface to the StateInterface

export interface StateInterface {
...
-  example: unknown
+  contacts: ContactStateInterface;
}

At Line 41, we add the imported contacts module into the modules object of the store. This is the point of registration of the contacts module. At Line 43, we also add the plugins property to the store and register the createLogger Vuex plugin. We pass in an object containing logActions and logMutations properties into the createLogger plugin so that the state of our store before and after each action and mutation will be logged.

export default store((/* { ssrContext } */) => {
  const Store = createStore<StateInterface>({
    modules: {
      // example
+      contacts,
    },
+    plugins: [createLogger({ logActions: true, logMutations: true })],

    // enable strict mode (adds overhead!)
    // for dev mode and --debug builds only
    strict: !!process.env.DEBUGGING,
  });
  return Store;
});

If you have gotten to this point, congratulations! You Vuex contacts module is ready. In the next lesson, we will connect our frontend components to the Vuex store.

Save all your files and commit the current changes. We are not done with this branch so we won't merge with the master branch yet.

git add .
git commit -m "feat(ui): create,  "