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 Contact
s 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 Contact
s. 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 Contact
s 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 Contact
s array to obtain a range of Contact
s 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 Contact
s, 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 thecurrentContact
from the result of the action dispatch instead of fetching from the Vuex store. The result of a promise is obtained in thethen
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 fromvue
is used to carry out further processing on thecurrentContact
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 prependingcontacts
to the name of the action as thetype
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, "