【Vuex】オブジェクトの配列をコンポーネントにバインドする方法

所得税・住民税・事業税・国民健康保険料計算シミュレーションを作成した際、propsとemitを使ったコンポーネント間の値の受け渡しのみだと限界を感じはじめ、Vuexによる状態管理を導入しました。その実装を進めていく上で一番困ったのが、オブジェクトの配列をバインドする方法でした。
いまだに最適解はわかりませんが、現時点ではこうしていくか!というまとめになります。

環境

  • Vue.js 2.5.17
  • Vuex 3.0.1

バインドするオブジェクトの構造

月毎の収入を保持するオブジェクトの配列です。
※ 以下の構造は所得税・住民税・事業税・国民健康保険料の計算ミューレーションで実際に使用している構造です。

store.js

// 月毎の収入を保持するオブジェクト
const months = [
  {'month': 1, 'value': 10 },
  {'month': 2, 'value': 20 },
  {'month': 3, 'value': 30 },
  {'month': 4, 'value': 40 },
  {'month': 5, 'value': 50 },
  {'month': 6, 'value': 60 },
  {'month': 7, 'value': 70 },
  {'month': 8, 'value': 80 },
  {'month': 9, 'value': 90 },
  {'month': 10, 'value': null },
  {'month': 11, 'value': null },
  {'month': 12, 'value': null },
]

これをVuexのストア構造のstateに設定し状態管理します。

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)

const store = new Vuex.Store({
  strict: process.env.NODE_ENV !== 'production',
  state: {
    months: months,
  },
  getters: {
    months(state) { return state.months }
  },
  mutations: {
    setMonths(state, payload) {
      state.months = payload.months
    }
  },
  actions: {
    doUpdateMonths({commit}, months) {
      commit('setMonths', { months })
    }
  }
}

ここからの説明では、storeはどこでも参照できるようにVueアプリケーションのルートに登録ていることが前提です。
vue-cliのwebpack形式で作成したプロジェクトの場合は、main.jsですね。

import store from '@/store.js'
new Vue({
  el: '#app',
  store: store,
  ~
})

失敗例

失敗1. dataオプションのようにバインドする

最初はだれしもやって失敗するはず。。

<template>
  <div id="months">
    <div v-for="item in this.$store.getters.months" :key="item.month">
      <label>{{ item.month }}月<input v-model="item.value" /></label>
    </div>
  </div>
</template>

画面はエラーになりませんが、入力するとミューテーション外から値を変更すんな!って感じで怒られてしまいます。。

"Error: [vuex] Do not mutate vuex store state outside mutation handlers."

失敗2. 算出プロパティのgetter、setterを使ってみる

<template>
  <div id="months">
    <div v-for="item in months" :key="item.month">
    ~

<script>
export default {
  computed: months: {
    get() { return this.$store.getters.months },
    set(value) { this.$store.dispatch('doUpdateMonths', value) }
  }
}
</script>

バインドしているのは、配列の要素に対してなので上記セッターはもちろん呼ばれず直接バインドと同じエラーがでます。

実装方法

案1. v-modelは使わず値の表示と変更イベントを別々にバインドする方法

v-bind:valueで値の表示を、v-on:inputで値の変更時にvalueを更新するためのメソッドをコールします。
更新時にはどの月かの識別が必要になるので、引数指定しておきます。

<template>
  <div id="months">
    <div v-for="item in this.$store.getters.months" :key="item.month">
      <label>{{ item.month }}月<input v-bind:value="item.value" v-on:input="updateValue($event, item.month)" /></label>
    </div>
  </div>
</template>

<script>
export default {
  methods: {
    updateValue(event, month) {
      this.$store.dispatch('doUpdateMonthsValue', { month: month, value: event.target.value })
    }
  }
}
</script>

Vuesのアクションとミューテーションを以下のように変更します。

store.js

const store = new Vuex.Store({
  ...

  mutations: {
    setMonthsValue(state, payload) {
      state.months.find((item) => item.month === payload.month).value = payload.value
    }
  },
  actions: {
    doUpdateMonthsValue({commit}, payload) {
      commit('setMonths', payload)
    }
  }

アクションの引数は、以下のような書き方の方がパラメータがパッと見てわかるので好みだけど。

doUpdateMonthsValue({commit}, { month, value}) {
      commit('setMonths', {month, value})
    }
  • 利点
    dataオプションを使っている箇所からの移行の場合、元の構造を維持しやすい。
  • 欠点
    更新したい項目が複数ある場合その分ミューテーションとアクションを用意する必要があるのでその分煩雑になる。

ちなみに、更新対象のプロパティが増えてきそうな場合は、以下のような構造にしてミューテーションとアクションをまとめるようにしておく位ですかね。

const store = new Vuex.Store({
  ...

  mutations: {
    setMonthsValueByProperty(state, payload) {
      state.months.find((item) => item.month === payload.month)[payload.property] = payload.value
    }
  },
  actions: {
    doUpdateMonthsValueByProperty({commit}, {month, property, value}) {
      commit('setMonths', {month, property, value})
    }
  }

以下のように使用

<script>
export default {
  methods: {
    updateValue(event, month) {
      this.$store.dispatch('doUpdateMonthsValueByProperty', { month: month, property: 'value', value: event.target.value })
    }
  }
}
</script>

案2. v-modelを何が何でも使う方法

vuexのstateをdataオプションにコピーしそちらをバインドし、vuexへの更新は別途実装します。

store.js

const store = new Vuex.Store({
  ...

  mutations: {
    setMonths(state, payload) {
      let months = []
      payload.months.forEach((m) => {
        months.push(Object.assign({}, m))
      })

      state.months = months
    }
  },
  actions: {
    doUpdateMonths({commit}, months) {
      commit('setMonths', { months })
    }
  }
}

コンポーネント側

<template>
  <div id="months">
    <div v-for="item in months" :key="item.month">
      <label>{{ item.month }}月<input v-model="item.value" /></label>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      months: []
    }
  },
  created: function() {
    this.months.forEach((m) => {
      this.months.push(Object.assign({}, m))
    }
  },
  methods: {
    updateMonths() {
      this.$store.dispatch('doUpdateMonths', 'months': this.months)
    }
  }
}
</script>
  • 利点
    テンプレートはほとんど修正の必要がない。
    Vuexのミューテーションとアクションも必要最低限。
  • 欠点
    vuex側への更新処理タイミングを別途考慮しなければいけない。
    オブジェクトのプロパティ値がオブジェクトだと参照になってしまうので使えない。

まとめ

どちらも冗長さがありますが、現在はケースバイケースで使い分けしてます。
Vue.jsもといJavaScriptの知見があれば、もっと簡潔にわかりやすく書けるようになるのだろうか。。
コード量を少なくしようとすると、途端にわかりづらいプログラムになっていくなぁという印象でした。

参考書はこちら。