Organizing 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 splits 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:

  1. A repository that contains the logic for interacting with the backend;
  2. A module for Vuex that works with the repository;
  3. 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 writing new code to interact with the backend and storage (store) almost to zero.

Creating a Vuex module factory

1. Base repository

Base repository BaseRepository unifies 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 further refer to (for example: /v1/users).

There are also two helper methods worth noting:
query – is just responsible for fulfilling requests.

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 execute a request to get the collection and look at the header Content-Range, which contains the total number of elements: Content-Range: <unit> <range-start>-<range-end>/<size>

// 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 – get a partial 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 method listAllthat gets all available items. First, using the method getTotaldescribed above, we get the number of available items. Then we load the elements in batches by chunkSize and combine them into one collection.

Of course, the implementation of getting all elements may be different.

BaseRepository.js

import axios from 'axios';

// parameterize :: replace substring in string by template
// parameterize :: Object -> String -> String
// parameterize :: {userId: '123'} -> '/users/:userId/activate' -> '/users/123/activate'
const parameterize = (url, urlParameters) => Object.entries(urlParameters)
  .reduce(
    (a, [key, value]) => a.replace(`:${key}`, value), 
    url,
  );

// responsesToCollection :: Array -> Array
// responsesToCollection :: [{data: [1, 2]}, {data: [3, 4]}] -> [1, 2, 3, 4]
const responsesToCollection = responses => responses.reduce((a, v) => a.concat(v.data), []);

// getContentRangeSize :: String -> Integer
// getContentRangeSize :: "Content-Range: items 0-137/138" -> 138
const getContentRangeSize = header => +/(w+) (d+)-(d+)/(d+)/g.exec(header)[4];

// getCollectionAndTotal :: Object -> Object
// getCollectionAndTotal :: { data, headers } -> { collection, total }
const getCollectionAndTotal = ({ data, headers }) => ({
  collection: data,
  total: headers['Content-Range'] && getContentRangeSize(headers['Content-Range']),
})

export default 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;
  }

  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']);
  }

  async list(queryParameters, urlParameters) {
    const result = await this.query({ urlParameters, queryParameters });

    return { 
      ...getCollectionAndTotal(result),
      params: queryParameters,
    };
  }

  async listAll(queryParameters = {}, urlParameters, chunkSize = 100) {
    const params = { 
      ...queryParameters,
      offset: 0,
      limit: chunkSize,
    };

    const requests = [];
    const total = await this.getTotal(urlParameters, queryParameters);

    while (params.offset < total) {
      requests.push(
        this.query({ 
          urlParameters, 
          queryParameters: params,
        }),
      );

      params.offset += chunkSize;
    }

    const result = await Promise.all(requests);

    return {
      total,
      params: {
        ...queryParameters,
        offset: 0,
        limit: total,
      },
      collection: responsesToCollection(result),
    };
  }

  async create(requestBody, urlParameters) {
    const { data } = await this.query({
      method: 'POST',
      urlParameters,
      data: requestBody,
    });

    return data;
  }

  async get(id = '', urlParameters, queryParameters = {}) {
    const { data } = await this.query({
      method: 'GET',
      nestedEndpoint: `/${id}`,
      urlParameters,
      queryParameters,
    });

    return data;
  }

  async update(id = '', requestBody, urlParameters) {
    const { data } = await this.query({
      method: 'PUT',
      nestedEndpoint: `/${id}`,
      urlParameters,
      data: requestBody,
    });

    return data;
  }

  async delete(id = '', requestBody, urlParameters) {
    const { data } = await this.query({
      method: 'DELETE',
      nestedEndpoint: `/${id}`,
      urlParameters,
      data: requestBody,
    });

    return data;
  }
}

To start working with our API, you just need to specify the name of the resource.
For example, from the resource users get a specific user:

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 need to activate a user by sending a POST request to /v1/users/:id/activate
To do this, we will create additional methods, for example:

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:

const usersRepository = new UsersRepository();
await usersRepository.activate('USER-007');
await usersRepository.listAll();

2. 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:

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 element;
  • total – the total number of elements.

Action games

Actions must be created in the module that will work with 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 storage factory will serve modules that need to be registered in the store using the method registerModule: 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

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:

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:

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),
                });
            }
        }
    },
);

3. 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

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 turned out to be very simple. For example, creating a module for managing users (including a custom action to activate a user) is described by one object:

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, with the exception of one point: we set new names for actions and getters so that there are no collisions in the names:

{
    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 need 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:

ResourceFactory(
    store,
    {
        name: 'userOrders',
        endpoint: 'users/:userId/orders',
    },
);

Working with a module in a component:

{
    ...

    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 frontend until the backend is ready.
If the continuation is interesting, then write about it in the comments – I will definitely write a sequel and tell you about how we solved these problems.

conclusions

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 header is not suitable for everyone. Content-Range, 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.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *