0

I'm trying to set up a Vue component that takes a flat list of items in an array, groups them by a property for use in a sub-component, and emits the updated flat array.

My section component uses these grouped items in their v-model and emits the updated list. The section component is a drag-and-drop with some input fields, so items are changed under the section component and the updated list is emitted.

Here's an example of the component that takes the flat list as a prop:

<template>
    <div>
        <div v-for="section in template.sections" :key="section.id">
            <h2>{{ section.name }}</h2>
            <item-section :section="section" v-model="sectionData[section.id]"></item-section>
        </div>
    </div>
</template>

<script type="text/javascript">
import { groupBy } from "lodash";
import ItemSection from "@/components/Section.vue";

export default {
    name: "ItemAssignment",
    props: {
        // All items in flat array
        value: {
            type: Array,
            required: true,
            default: () => [
                /**
                 * {
                 *  id: null,
                 *  section_id: null,
                 *  name: null  
                 * }
                 */
            ]
        },
        // Template (containing available sections)
        template: {
            type: Object,
            default: () => {
                return {
                    sections: [
                        /**
                         * {
                         *  id: null,
                         *  name: null  
                         * }
                         */             
                    ]
                };
            }
        }
    },
    components: {
        ItemSection
    },
    data() {
        return {
            sectionData: []
        };
    },
    mounted() {},
    computed: {
        flattenedData() {
            return Object.values(this.sectionData).flat();
        }
    },
    methods: {},
    watch: {
        // Flat list updated
        value: {
            immediate: true,
            deep: true,
            handler(val) {
                this.sectionData = groupBy(val, "section_id");
            }
        },
        // --- Causing infinite loop ---
        // flattenedData(val) {
        //  this.$emit("input", val);
        // },
    }
};
</script>

The parent of this component is basically this:

<template>
    <div>
        <!-- List items should be updatable here or from within the assignment component -->
        <item-assignment v-model="listItems"></item-assignment>
    </div>
</template>

<script type="text/javascript">
import ItemAssignment from "@/components/ItemAssignment.vue";

export default {
    name: "ItemExample",
    props: {

    },
    components: {
        ItemAssignment
    },
    data() {
        return {
            listItems: []
        };
    },
    mounted() {},
    computed: {

    },
    methods: {
        // Coming from API...
        importExisting(list) {
            var newList = [];

            list.forEach(item => {
                const newItem = {
                    id: null, // New record, so don't inherit ID
                    section_id: item.section_id,
                    name: item.name
                };

                newList.push(newItem);
            });

            this.listItems = newList;
        }
    },
    watch: {

    }
};
</script>

When emitting the finalized flat array, Vue goes into an infinite loop trying to re-process the list and the browser tab freezes up.

I believe the groupBy and/or Object.values(array).flat() method are stripping the reactivity out so Vue constantly thinks it's different data, thus the infinite loop.

I've tried manually looping through the items and pushing them to a temporary array, but have had the same issue.

If anyone knows a way to group and flatten these items while maintaining reactivity, I'd greatly appreciate it. Thanks!

  • Have you tried breaking up v-model into value and @input? Because that's basically what v-model is comprised of. – Cathy Ha Aug 14 '19 at 19:55
  • Yup. Tried :value and @input to a method that did the flattening, but had the same issue once that new flattened array was emitted in this component. Do you mean trying that on the parent component of this one? – Kyle Weishaupt Aug 14 '19 at 20:06
  • So I was thinking you can bind the value of item-section to a computed property (which is the grouped version of your flat array). On input of item-section, use a method to update the flat array. This way you can delete the watch on the flat array – Cathy Ha Aug 14 '19 at 20:17
  • is ti the `$emit` that's causing the loop or the listener of the parent component? Can you share the parent component's use of the `ItemAssignment`? – Daniel Aug 14 '19 at 21:21
  • Thank you for the suggestion. If I use a computed setter, it doesn't get set on update. I seem to need a deep watcher to detect reordering & item value updates. Any time I pass in a new list to this component, I get the constant in/out loop though. – Kyle Weishaupt Aug 14 '19 at 21:31
  • @Daniel The `$emit` loop comes from this component letting the parent know it's list has changed, and somehow that value being different on the `value` watcher that groups the items. – Kyle Weishaupt Aug 14 '19 at 21:33

1 Answers1

2

So it makes sense why this is happening...

The groupBy function creates a new array, and since you're watching the array, the input event is triggered which causes the parent to update and pass the same value which gets triggered again in a loop.

Since you're already using lodash, you may be able to include the isEqual function that can compare the arrays

import { groupBy, isEqual } from "lodash";
import ItemSection from "@/components/Section.vue";

export default {
// ...redacted code...
    watch: {
        // Flat list updated
        value: {
            immediate: true,
            deep: true,
            handler(val, oldVal) {
                if (!isEqual(val, oldVal))
                    this.sectionData = groupBy(val, "section_id");
            }
        },
        flattenedData(val) {
           this.$emit("input", val);
        },
    }
};

this should prevent the this.sectionData from updating if the old and new values are the same.

this could also be done in flattenedData, but would require another value to store the previous state.

Daniel
  • 34,125
  • 17
  • 102
  • 150