1 year ago

#72256

test-img

David Moles

Concise, modular approach to manipulating deeply nested data structures in VueX

I have a Vue component displaying a list of items, that for each item allows users to select zero or more terms, as well as editing other properties.

The data for the component consists of a list of all terms, and a keyed object items created dynamically based on the directory property of each item.

terms: [ { id: 1, name: 'Fall 2021' }, { id: 2, name: 'Spring 2022' }, ... ],
items: {
  'b123_c456': { 
    directory: 'b123_c456', copies: 3, terms: [ { id: 1, name: 'Fall 2021' } ]
  },
  'b789_c012': {
    directory: 'b789_c012', copies: 2, terms: [ { id: 1, name: 'Fall 2021' }, ... ]
  }
  ...
}

I'm trying to migrate this component to VueX.

The read-only terms module is pretty straightforward:

const state = () => ({ all: [] })
const mutations = {
  setTerms (state, termArray) { state.all = termArray }
}
const actions = {
  reload ({ commit }) {
    termsApi.getTerms().then(terms => commit('setTerms', terms))
  }
}

and the items module started out fairly straightforward as well:

const state = () => ({ all: {} })

But as I add editing functionality for individual items, it's becoming quite convoluted.

Following the computed-property pattern from this answer, I was able to create a fairly simple v-model-based component allowing me to edit the terms for a single item:

<template>
  <ul>
    <li v-for="term in terms" :key="`${item.id}-term-${term.id}`">
      <input :id="`${item.id}-term-${term.id}`" v-model.lazy="itemTerms" type="checkbox" :value="term" @change="updateItem(item)">
      <label :for="`${item.id}-term-${term.id}`">{{ term.name }}</label>
    </li>
  </ul>
</template>

<script>
import { mapActions, mapMutations, mapState } from 'vuex'

export default {
  props: { directory: { type: String, default: null } },
  computed: {
    item: {
      get () { return (this.$store.state.items.all)[this.directory] }
    },
    itemTerms: {
      get () { return this.item.terms },
      set (terms) {
        const item = this.item
        this.setItemTerms({ item, terms })
      }
    },
    ...mapState({ terms: state => state.terms.all })
  },
  methods: {
    ...mapMutations({ setItemTerms: 'items/setItemTerms' }),
    ...mapActions({ updateItem: 'items/updateItem' })
  }
}
</script>

but only at the cost of adding a setItemTerms mutation to the items store module:

const mutations = {
  setItem (state, item) {
    state.all[item.directory] = item
  },
  setItemTerms (state, { item, terms }) {
    const it = state.all[item.directory]
    if (it) {
      it.terms = terms
    }
  },
}

const actions = {
  updateItem ({ commit }, item) {
    itemApi.update(item)
      .then(item => commit('setItem', item))
  },
}

If I continue down this path, I'm going to end up littering the items module with mutations for each item property in addition to operations on the list as a whole, which makes the code harder to read and feels like a Law of Demeter violation.

The next thing I tried was to create an item module that just handles a single object, and then register an instance of it for each item loaded from the API.

Having split out the item module as follows -- with an extra level of indirection to make it possible to inject the data:

const state = () => ({ item: {} })
const mutations = {
  set (state, item) { state.item = item }
}

I simplified the items module to this:

import item from './item'
const state = () => ({})
const actions = {
  setItems ({ commit }, itemArray) {
    for (const it of itemArray) {
      // this.registerModule(it.directory, item)         // <-- not scoped, doesn't work
      this.registerModule(['items', it.directory], item) // <-- explicitly scoped
      commit(`${it.directory}/set`, item)
    }
  }
}

I can then create a component for each individual item taking the directory as a property and using that to look the item up in the global state:

export default {
  props: { directory: { type: String, default: null } },
  computed: {
    item: {
      get () {
        return this.$store.state.items[this.directory].item
      }
    },
    ...mapState({ terms: state => state.terms.all })
  },
  methods: {
    updateItem () {
      this.$store.dispatch(`items/${this.directory}/update`)
    }
  }
}

However, this is pretty verbose, since it doesn't allow me to use mapState, and the extra level of indirection (items[dir].item) makes it extra-clunky. It's making me want to ditch VueX and just use data. I also don't like the fact that the item component has to know the path all the way from the root state and has to look up its data in the root store based on the directory parameter -- it'd be cleaner if, inside the item component, this.$store.state was just the item data. (And while I've omitted the details here, iterating over the dynamically added items was an additional headache, involving maintaining a separate array of directories.)

It seems like there should be a way to create a self-contained item module and a corresponding, self-contained component -- a module and component that would work wherever the item ends up in the state tree.

What's the VueX best practice for elegantly manipulating nested data structures of this kind?

vue.js

vuex

state-management

0 Answers

Your Answer

Accepted video resources