1 year ago
#72256
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