VueX Modulator - Part 1: generating VueX modules

This article will be a step by step explanation of the creation of a VueX module generator. Other articles will follow concerning a _data dispatcher_ and the usage of all this with Nativescript-Vue

When I started using Vue last year I worked with an API, so the data I got from it was nice and structured. I used to create one VueX module per data type (i.e.: users, posts, …) and I quickly hit these questions: How can I manage to automatize the module creation? How can I dispatch incoming data in the right module?

I answered to those two questions with this: I need a module generator, and some kind of data dispatcher. So I wrote them.

This article will be a step by step explanation of the creation of a module generator for VueX. A few other articles will follow concerning creation of a VueX “data dispatcher” and the usage of all this with Nativescript-Vue


Before we continue here, I assume you already use VueX.

We’ll use an imaginary library called api to make api calls. Every calls returns a promise.

A repo exists if you want to check the file structure the commits, etc:

[github-repo-card:el-cms/example-vuex-module-generator]

VueX modules are repetitive

A VueX module have this typical structure:

const exampleModule = {
  // Current state
  state: {
    message: '',
  },
  // Sync methods to change the state
  mutations: {
    setMessage: (state, message) => { state.message = message },
  },
  // Async methods that prepare state change
  actions: {
    SET_MESSAGE: ({commit}, message) => { commit('setMessage', message)},
  },
  // Methods to get data from the store.
  getters: {
    GET_MESSAGE: state => state.message,
  },
}
// I used to write actions in SCREAMING_CASE, but it's changing ;)

For a post data type with CRUD actions, the module would look like

// We need Vue to ensure proper updates in mutations
import Vue from 'vue'

const articlesModule = {
  // Current state
  state: {},
  // sync methods to change the state
  mutations: {
    setArticle: (state, article) => { Vue.set(state, article.id, article) },
    removeArticle: (state, id) => {Vue.delete(state, id)},
  },
  // async methods that prepare state change (can be considered as setters)
  actions: {
    LOAD_ARTICLES: ({commit}) => {
      api.get('articles')
        .then((data) => {
          for (let i = 0; i < data.length; i++) {
            commit('setArticle', data[i])
          }
        })
        .catch((error) => {console.log('ERROR in LOAD_ARTICLES:', error)})
    },
    LOAD_ARTICLE: ({commit}, id) => {
      api.get(`articles/${id}`)
        .then((data) => {commit('setArticle', data)})
        .catch((error) => {console.log('ERROR in LOAD_ARTICLE:', error)})
    },
    CREATE_ARTICLE: ({commit}, payload) => {
      api.post('articles', payload)
        .then((data) => {
          // Let's assume "data" is the new article, sent back by the API
          // We don't want to store the user input in our store :)
          commit('setArticle', data)
        })
        .catch((error) => {console.log('ERROR in CREATE_ARTICLE:', error)})
    },
    UPDATE_ARTICLE: ({commit}, payload) => {
      api.patch(`articles/${payload.id}`, payload)
        .then((data) => {
          // Let's assume "data" is the updated article
          commit('setArticle', data)
        })
        .catch((error) => {console.log('ERROR in UPDATE_ARTICLE:', error)})
    },
    DESTROY_ARTICLE: ({commit}, id) => {
      api.delete(`articles/${id}`)
        .then(() => { commit('removeArticle', id) })
        .catch((error) => {console.log('ERROR in DESTROY_ARTICLE:', error)})
    },
  },
  // methods to get data from the store.
  getters: {
    articles: state => state,
    articles
  },
}

That’s a lot of characters, and if we want to add, let’s say a getter, on a module, we have to update every modules. It’s hard to maintain, and not fun to write.

Creating a module generator

(I called mine “Modulator”, for MODULe generATOR, so it’s the name used here.)

Now if I wanted a data type for comments, the only changing things are the endpoints (“comments” instead of “and methods names.

But what if:

Here is what the generator might be:

// Let's save this file: src/modulator/index.js

// We need Vue to ensure proper updates in mutations
import Vue from 'vue'

const camelize = function (snakeText, capitalizeFirstLetter = true) {
  let regexp = /(_\w)/g
  if (capitalizeFirstLetter) {
    regexp = /(^\w|_\w)/g
  }
  const out = snakeText.replace(regexp, (match) => {
    if (match.length > 1) {
      return match[1].toUpperCase()
    } else {
      return match.toUpperCase()
    }
  })
  console.log(snakeText, capitalizeFirstLetter, out)
  return out
}

export default function (endpoint, singular, plural, editable = true, destroyable = true) {
  // Prepare some SCREAMING_NAMES
  const lowCamelSingular = camelize(singular, false)
  const lowCamelPlural = camelize(plural, false)
  const camelSingular = camelize(singular)
  const camelPlural = camelize(plural)

  // Empty module
  const module = {state: {}, mutations: {}, actions: {}, getters: {}}

  // Mutators:
  module.mutations[`set_${singular}`] = (state, entity) => { Vue.set(state, entity.id, entity) }
  module.mutations[`remove_${singular}`] = (state, id) => {Vue.delete(state, id)}

  // Actions:
  module.actions[`load${camelPlural}`] = ({commit}) => {
    api.get(endpoint)
      .then((data) => {
        for (let i = 0; i < data.length; i++) {
          commit(`set_${singular}`, data[i])
        }
      })
      .catch((error) => {console.log(`ERROR in load${camelPlural}:`, error)})
  }
  module.actions[`load${camelSingular}`] = ({commit}, id) => {
    api.get(`${endpoint}/${id}`)
      .then((data) => {commit(`set_${singular}`, data)})
      .catch((error) => {console.log(`ERROR in load${camelSingular}:`, error)})
  }
  if (editable) {
    module.actions[`create${camelSingular}`] = ({commit}, payload) => {
      api.post(endpoint, payload)
        .then((data) => {
          // Let's assume "data" is the new article, sent back by the API
          // We don't want to store the user input in our store :)
          commit(`set_${singular}`, data)
        })
        .catch((error) => {console.log(`ERROR in create${camelSingular}:`, error)})
    }
    module.actions[`update${camelSingular}`] = ({commit}, payload) => {
      api.patch(`${endpoint}/${payload.id}`, payload)
        .then((data) => {
          // Let's assume "data" is the updated article
          commit(`set_${singular}`, data)
        })
        .catch((error) => {console.log(`ERROR in update${camelSingular}:`, error)})
    }
  }
  if (destroyable) {
    module.actions[`destroy${camelSingular}`] = ({commit}, id) => {
      api.delete(`${endpoint}/${id}`)
        .then(() => { commit(`remove_${singular}`, id) })
        .catch((error) => {console.log('ERROR in DESTROY_ARTICLE:', error)})
    }
  }

  // Getters
  module.getters[lowCamelPlural] = state => state
  module.getters[lowCamelSingular] = state => id => state[id] || undefined

  return module
}

Note: If the API have different endpoints, as article/1 and articles, you can create a parameter like this: endpoint = {one: '', many: ''} and use it like api.get(`${endpoint.one}/${id}`)

You can now use it in your store.

Basic usage of the Modulator

For the integration and usage, I assume you have this setup:

// src/main.js

import Vue from 'vue'
// ... other imports
import store from './store'

//...
new Vue({
  // ...
  store,
  // ...
})
// src/store.js
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default new Vuex.Store({
  //... your store
})

To use the new modulator, edit src/store/index.js:

// src/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import moduleGenerator from './modulator'

Vue.use(Vuex)

export default new Vuex.Store({
  // ...
  modules: {
    users: moduleGenerator('users', 'user', 'users', true, false),
    posts: moduleGenerator('posts', 'post', 'posts'),
    postTypes: moduleGenerator('post_types', 'post_type', 'post_types'),
  }
})

This is the end of the first part; corresponding with commit #866fd148.

Custom modules

With this module generator, we can create more getters, setters, etc… for all our content types, that’s very generic. But what if we need to customize one of the generated modules?

The proposed solution here is to export an object with all the different methods used in module generation instead of the current function.

Decomposition

We should decompose the modulator in more methods: one for each section of a module:

const mutations = function (singular) {
  const mutations = {}
  mutations[`set_${singular}`] = (state, entity) => { Vue.set(state, entity.id, entity) }
  mutations[`remove_${singular}`] = (state, id) => {Vue.delete(state, id)}

  return mutations
}
const actions = function(endpoint, singular, plural, editable = true, destroyable = true){
  const camelSingular = camelize(singular)
  const camelPlural = camelize(plural)
  const actions = {}

  actions[`load${camelPlural}`] = ({commit}) => {
    api.get(endpoint)
      .then((data) => {
        for (let i = 0; i < data.length; i++) {
          commit(`set_${singular}`, data[i])
        }
      })
      .catch((error) => {console.log(`ERROR in load${camelPlural}:`, error)})
  }
  actions[`load${camelSingular}`] = ({commit}, id) => {
    api.get(`${endpoint}/${id}`)
      .then((data) => {commit(`set_${singular}`, data)})
      .catch((error) => {console.log(`ERROR in load${camelSingular}:`, error)})
  }
  if (editable) {
    actions[`create${camelSingular}`] = ({commit}, payload) => {
      api.post(endpoint, payload)
        .then((data) => {
          // Let's assume "data" is the new article, sent back by the API
          // We don't want to store the user input in our store :)
          commit(`set_${singular}`, data)
        })
        .catch((error) => {console.log(`ERROR in create${camelSingular}:`, error)})
    }
    actions[`update${camelSingular}`] = ({commit}, payload) => {
      api.patch(`${endpoint}/${payload.id}`, payload)
        .then((data) => {
          // Let's assume "data" is the updated article
          commit(`set_${singular}`, data)
        })
        .catch((error) => {console.log(`ERROR in update${camelSingular}:`, error)})
    }
  }
  if (destroyable) {
    actions[`destroy${camelSingular}`] = ({commit}, id) => {
      api.delete(`${endpoint}/${id}`)
        .then(() => { commit(`remove_${singular}`, id) })
        .catch((error) => {console.log('ERROR in DESTROY_ARTICLE:', error)})
    }
  }
}
const getters = function (singular, plural) {
  const lowCamelSingular = camelize(singular, false)
  const lowCamelPlural = camelize(plural, false)

  const getters = {}

  getters[lowCamelPlural] = state => state
  getters[lowCamelSingular] = state => id => state[id] || undefined

  return getters
}

Make a function out of the previously exported function:

// export default function (endpoint, singular, plural, editable = true, destroyable = true) {
//  ... old code
// }
// becomes:

export default function (endpoint, singular, plural, editable = true, destroyable = true) {
  const module = {
    state: {}, mutations: {
      ...mutations(singular),
    }, actions: {
      ...actions(endpoint, singular, plural, editable, destroyable),
    }, getters: {
      ...getters(singular, plural),
    },
  }

  return module
}

See commit #c1b04031

Recomposition

Now we have exactly the same behavior as before, but with smaller methods. So it does not answer the question “How to customize a module created with the generator?”.

The idea is now to export all these new methods, so we can compose our modules on demand: move them in an object and export it.

export default {
  mutations (singular) {
    // code
  },
  actions (endpoint, singular, plural, editable = true, destroyable = true) {
    // code
  },
  getters (singular, plural) {
    // code
  },
  generateModule (endpoint, singular, plural, editable = true, destroyable = true) {
    return {
      state: {},
      mutations: {
        ...this.mutations(singular),
      }, actions: {
        ...this.actions(endpoint, singular, plural, editable, destroyable),
      }, getters: {
        ...this.getters(singular, plural),
      },
    }
  },
}

As the export changed, we should update src/store.js: commit #3f067022

// src/store.js

import Vue from 'vue'
import Vuex from 'vuex'
import Modulator from './modulator'

Vue.use(Vuex)

export default new Vuex.Store({
  state: {},
  mutations: {},
  actions: {},
  modules: {
    users: Modulator.generateModule('users', 'user', 'users', true, false),
    posts: Modulator.generateModule('posts', 'post', 'posts'),
    postTypes: Modulator.generateModule('post_types', 'post_type', 'post_types'),
  }
})

Usage

Now we can build custom modules:

export default new Vuex.Store({
  state: {},
  mutations: {},
  actions: {},
  modules: {
    users: Modulator.generateModule('users', 'user', 'users', true, false),
    posts: Modulator.generateModule('posts', 'post', 'posts'),
    postTypes: Modulator.generateModule('post_types', 'post_type', 'post_types'),
    comments: {
      state: {},
      mutations: {
        ...Modulator.mutations('comment'),
        my_custom_mutation: (state, data) => {/* do something */},
      },
      actions: {
        ...Modulator.actions('comments', 'comment', 'comments'),
        createComment: ({commit}, payload) => {/* override an action from the Modulator */},
      },
      getters: {
        ...Modulator.getters('comment', 'comments'),
      },
    },
  },
})

Visible in commit #315bc5a8

Conclusion

Creating Vuex module generators has been really useful; I used this system on every project where an API was involved. Sadly, every API is different and there was always small code differences in the way the responses were handled.

I’d really like to make a generic npm dependency out of it, but I don’t know where to start.

If you have a comment about this, leave it here, i’ll be happy to update my post. And if you want to talk about it, join me on Gitter !