Organization of Generic Modules in Vuex
Vuex is the official application state management library designed specifically for the Vue.js framework.
Vuex implements a state management pattern that serves as a centralized data store for all components of an application.
As the application grows, this storage grows and the application data is placed in one large object.
To solve this problem, Vuex divides the storage into modules. Each such module can contain its own state, mutations, actions, getters, and built-in submodules.
Working on the CloudBlue Connect project and creating another module, we caught ourselves thinking that we were writing the same boilerplate code over and over again, changing only the endpoint:
- A repository that contains the logic for interacting with the backend;
- A module for Vuex that works with the repository;
- Unit tests for repositories and modules.
In addition, we display data in a single list or table view with the same sorting and filtering mechanisms. And we use almost the same data extraction logic, but with different endpoints.
In our project, we love to invest in writing reusable code. In addition to the fact that this reduces development time in the long term, it reduces the number of possible bugs in the code.
To do this, we created a factory of standard Vuex modules, which reduced the writing of new code to interact with the backend and storage (store) almost to zero.
Creating a Vuex module factory
- Base repository
The BaseRepository unifies the work with the backend via the REST API. Since these are normal CRUD operations, I will not dwell on the implementation in detail, but will only focus on a couple of main points.
When creating an instance of a class, you need to specify the name of the resource and, optionally, the version of the API.
On the basis of them, an endpoint will be formed, which we will refer to further (for example: / v1 / users).
There are also two helper methods worth noting:
- query – just responsible for executing queries.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
class BaseRepository { constructor (entity, version = 'v1') { this.entity = entity; this.version = version; } get endpoint () { return `/ $ {this.version} / $ {this.entity}`; } async query ({ method = 'GET', nestedEndpoint = '', urlParameters = {}, queryParameters = {}, data = undefined, headers = {}, }) { const url = parameterize (`$ {this.endpoint} $ {nestedEndpoint}`, urlParameters); const result = await axios ({ method, url, headers, data, params: queryParameters, }); return result; } ... } |
- getTotal – gets the total number of items.
In this case, we are making a request to get the collection and looking at the Content-Range header, which contains the total number of elements: Content-Range: – / .
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// getContentRangeSize :: String -> Integer // getContentRangeSize :: "Content-Range: items 0-137/138" -> 138 const getContentRangeSize = header => +/(\w+) (\d+)-(\d+)\/(\d+)/g.exec(header)[4]; ... async getTotal(urlParameters, queryParameters = {}) { const { headers } = await this.query({ queryParameters: { ...queryParameters, limit: 1 }, urlParameters, }); if (!headers['Content-Range']) { throw new Error('Content-Range header is missing'); } return getContentRangeSize(headers['Content-Range']); } |
The class also contains the main methods for working:
- listAll – get the entire collection;
- list – partially get a collection (with pagination);
- get – get an object;
- create – create an object;
- update – update the object;
- delete – delete an object.
All methods are simple: they send the appropriate request to the server and return the desired result.
I will separately explain the listAll method, which gets all the available elements. First, using the getTotal method described above, we get the number of available items. Then we load the elements in chunkSize batches and combine them into one collection.
Of course, the implementation of getting all elements may be different.
BaseRepository.js
To start working with our API, you just need to specify the name of the resource.
For example, get a specific user from the users resource:
1 2 |
const usersRepository = new BaseRepository('users'); const win0err = await usersRepository.get('USER-007'); |
What to do when additional actions need to be implemented?
For example, if you want to activate a user by sending a POST request to / v1 / users /: id / activate.
To do this, we will create additional methods, for example:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class UsersRepository extends BaseRepository { constructor () { super ('users'); } activate (id) { // POST / v1 / users /: id / activate return this.query ({ nestedEndpoint: '/: id / activate', method: 'POST', urlParameters: {id}, }); } } |
Now the API is very easy to work with:
1 2 3 |
const usersRepository = new UsersRepository (); await usersRepository.activate ('USER-007'); await usersRepository.listAll (); |
Factory storage
Due to the fact that we have a unified behavior in the repository, the module structure will be similar.
This feature allows you to create standard modules. Such a module contains state, mutations, actions, and getters.
Mutations
In our example, one mutation will be enough, which updates the values of objects.
As value, you can specify both the final value and pass the function:
1 2 3 4 5 6 7 8 9 10 11 12 |
import { is, clone, } from 'ramda'; const mutations = { replace: (state, { obj, value }) => { const data = clone(state[obj]); state[obj] = is(Function, value) ? value(data) : value; }, } |
State and getters
As a rule, the storage needs to store some kind of collection, or a specific element of the collection.
In the users example, this could be a list of users and detailed information about the user we want to edit.
Accordingly, at the moment, three elements are sufficient:
- collection – collection;
- current – the current item;
- total – the total number of items.
Action games
Actions must be created in the module that will work with the methods defined in the repository: get, list, listAll, create, update and delete. By interacting with the backend, they will update the data in the repository.
If you wish, you can create methods that allow you to install data into the storage without interacting with the backend.
Warehouse factory
The store factory will serve up modules that need to be registered in the store using the registerModule method: store.registerModule (name, module) ;.
When creating a generic store, we pass the repository instance and additional data that will be mixed into the repository instance as parameters. For example, it could be a method that will activate a user.
StoreFactory.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 |
import { clone, is, mergeDeepRight, } from 'ramda'; const keyBy = (pk, collection) => { const keyedCollection = {}; collection.forEach( item => keyedCollection[item[pk]] = item, ); return keyedCollection; } const replaceState = (state, { obj, value }) => { const data = clone(state[obj]); state[obj] = is(Function, value) ? value(data) : value; }; const updateItemInCollection = (id, item) => collection => { collection[id] = item; return collection }; const removeItemFromCollection = id => collection => { delete collection[id]; return collection }; const inc = v => ++v; const dec = v => --v; export const createStore = (repository, primaryKey = 'id') => ({ namespaced: true, state: { collection: {}, currentId: '', total: 0, }, getters: { collection: ({ collection }) => Object.values(collection), total: ({ total }) => total, current: ({ collection, currentId }) => collection[currentId], }, mutations: { replace: replaceState, }, actions: { async list({ commit }, attrs = {}) { const { queryParameters = {}, urlParameters = {} } = attrs; const result = await repository.list(queryParameters, urlParameters); commit({ obj: 'collection', type: 'replace', value: keyBy(primaryKey, result.collection), }); commit({ obj: 'total', type: 'replace', value: result.total, }); return result; }, async listAll({ commit }, attrs = {}) { const { queryParameters = {}, urlParameters = {}, chunkSize = 100, } = attrs; const result = await repository.listAll(queryParameters, urlParameters, chunkSize) commit({ obj: 'collection', type: 'replace', value: keyBy(primaryKey, result.collection), }); commit({ obj: 'total', type: 'replace', value: result.total, }); return result; }, async get({ commit, getters }, attrs = {}) { const { urlParameters = {}, queryParameters = {} } = attrs; const id = urlParameters[primaryKey]; try { const item = await repository.get( id, urlParameters, queryParameters, ); commit({ obj: 'collection', type: 'replace', value: updateItemInCollection(id, item), }); commit({ obj: 'currentId', type: 'replace', value: id, }); } catch (e) { commit({ obj: 'currentId', type: 'replace', value: '', }); throw e; } return getters.current; }, async create({ commit, getters }, attrs = {}) { const { data, urlParameters = {} } = attrs; const createdItem = await repository.create(data, urlParameters); const id = createdItem[primaryKey]; commit({ obj: 'collection', type: 'replace', value: updateItemInCollection(id, createdItem), }); commit({ obj: 'total', type: 'replace', value: inc, }); commit({ obj: 'current', type: 'replace', value: id, }); return getters.current; }, async update({ commit, getters }, attrs = {}) { const { data, urlParameters = {} } = attrs; const id = urlParameters[primaryKey]; const item = await repository.update(id, data, urlParameters); commit({ obj: 'collection', type: 'replace', value: updateItemInCollection(id, item), }); commit({ obj: 'current', type: 'replace', value: id, }); return getters.current; }, async delete({ commit }, attrs = {}) { const { urlParameters = {}, data } = attrs; const id = urlParameters[primaryKey]; await repository.delete(id, urlParameters, data); commit({ obj: 'collection', type: 'replace', value: removeItemFromCollection(id), }); commit({ obj: 'total', type: 'replace', value: dec, }); }, }, }); const StoreFactory = (repository, extension = {}) => { const genericStore = createStore( repository, extension.primaryKey || 'id', ); ['state', 'getters', 'actions', 'mutations'].forEach( part => { genericStore[part] = mergeDeepRight( genericStore[part], extension[part] || {}, ); } ) return genericStore; }; export default StoreFactory; |
Usage example
To create a standard module, it is enough to create an instance of the repository and pass it as an argument:
1 2 |
const usersRepository = new UsersRepository(); const usersModule = StoreFactory(usersRepository); |
However, as in the user activation example, the module must have a corresponding action.
Let’s pass it as a store extension:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
import { assoc } from 'ramda'; const usersRepository = new UsersRepository(); const usersModule = StoreFactory( usersRepository, { actions: { async activate({ commit }, { urlParameters }) { const { id } = urlParameters; const item = await usersRepository.activate(id); commit({ obj: 'collection', type: 'replace', value: assoc(id, item), }); } } }, ); |
Resource factory
It remains to put everything together into a single resource factory, which will first create the repository, then the module, and finally register it in the store:
ResourceFactory.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
import BaseRepository from './BaseRepository'; import StoreFactory from './StoreFactory'; const createRepository = (endpoint, repositoryExtension = {}) => { const repository = new BaseRepository(endpoint, 'v1'); return Object.assign(repository, repositoryExtension); } const ResourceFactory = ( store, { name, endpoint, repositoryExtension = {}, storeExtension = () => ({}), }, ) => { const repository = createRepository(endpoint, repositoryExtension); const module = StoreFactory(repository, storeExtension(repository)); store.registerModule(name, module); } export default ResourceFactory; |
Resource Factory Example
The use of standard modules is very simple. For example, creating a module for managing users (including a custom action to activate a user) is described by one object:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
const store = Vuex.Store(); ResourceFactory( store, { name: 'users', endpoint: 'users', repositoryExtension: { activate(id) { return this.query({ nestedEndpoint: '/:id/activate', method: 'POST', urlParameters: { id }, }); }, }, storeExtension: (repository) => ({ actions: { async activate({ commit }, { urlParameters }) { const { id } = urlParameters; const item = await repository.activate(id); commit({ obj: 'collection', type: 'replace', value: assoc(id, item), }); } } }), }, ); |
Inside the components, the use is standard, except for one thing: we set new names for actions and getters so that there are no collisions in the names:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
{ computed: { ...mapGetters('users', { users: 'collection', totalUsers: 'total', currentUser: 'current', }), ...mapGetters('groups', { users: 'collection', }), ... }, methods: { ...mapActions('users', { getUsers: 'list', deleteUser: 'delete', updateUser: 'update', activateUser: 'activate', }), ...mapActions('groups', { getAllUsers: 'listAll', }), ... async someMethod() { await this.activateUser({ urlParameters: { id: 'USER-007' } }); ... } }, } |
If you want to get any nested collections, then you need to create a new module.
For example, working with purchases made by a user might look like this.
Description and registration of the module:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
ResourceFactory( store, { name: 'userOrders', endpoint: 'users/:userId/orders', }, ); Работа с модулем в компоненте: { ... methods: { ...mapActions('userOrders', { getOrder: 'get', }), async someMethod() { const order = await this.getOrder({ urlParameters: { userId: 'USER-007', id: 'ORDER-001', } }); console.log(order); } } |
What can be improved
The resulting solution can be modified. The first thing that can be improved is the caching of results at the store level. The second is to add post-processors that will transform objects at the repository level. The third is to add support for mocks, so that you can develop the front-end until the back-end is ready.
If the continuation is interesting, then write about it in the comments – I will definitely write a continuation and tell you about how we solved these problems.
Summary
Writing your code in a DRY way will make it maintainable. This is also available thanks to the API design conventions in our team. For example, the method with determining the number of elements through the Content-Range header is not suitable for everyone, you may have another solution.
By creating a factory of such typical (generic) modules, we practically got rid of the need to write repetitive code and, as a result, reduced the time both for writing modules and writing unit tests. In addition, the code has become monotonous, and the number of random bugs has decreased.
I hope you enjoyed this solution. If you have any questions or suggestions, I will gladly answer in the comments.
Related Posts
Leave a Reply Cancel reply
Service
Categories
- DEVELOPMENT (103)
- DEVOPS (53)
- FRAMEWORKS (26)
- IT (25)
- QA (14)
- SECURITY (13)
- SOFTWARE (13)
- UI/UX (6)
- Uncategorized (8)