1

I would consider this as a fundamental question. It's about the principles of vue2/vue3 reactivity.

Suppose we have items with tags (many-to-many). They are stored in vuex like that (using normalizr approach):

export const state = () => ({
  itemsOrder: [1, 2, ...],
  itemsByIds: { 1: { id: 1, title: 'Item 1' }, 2: { id: 2, title: 'Item 2' } ... },
  tagsByIds: { 1: { id: 1, title: 'Tag 1' }, 2: { id: 2, title: 'Tag 2'}, ... },
  itemTags: [{ itemId: 1, tagId: 1 }, ...]
})

Our goal is to show items with tags. There's some <ItemWithTags> component that has to show item title and tags, assigned to that item. And - the very important condition - we have to avoid unnecessary re-rendering as much as possible. If a tag gets assigned to an item (a new entry is created in itemTags) then only that particular <ItemWithTags> component should receive the update.

How to do that?

We can not refer to state.itemTags array from inside the <ItemWithTags> because then the reference triggers every component to update when a new entry is pushed to this array. For simplicity suppose <ItemWithTags> does not have any reference to the $store, it uses props only.

Let's build the getter:

getters = {
  itemsWithTags (state) {
    return state.itemsOrder.map(id => ({
      id,
      item: state.itemsByIds[id],
      tags: state.itemTags
        .filter(itemTag => itemTag.itemId === id)
        .map(itemTag => state.tagsByIds[itemTag.tagId])
    })
  }
}

Notice, that when any new itemTag relation is created, this getter builds a new array that consists on new objects like {id, item, tags}, each object has a reference to the same item as before and a new tags array. For all items but one the tags array is internally the same - it consists on the same tag objects in the same order. But that's a new array for each entry.

Let's iterate over itemsWithTags in some parent component (for now just show items without tags):

<ItemWithTags
  v-for="entry in itemsWithTags"
  :key="entry.id"
  :item="entry.item"
/>

This works correctly - a new relation triggers the getter to rebuilt, but for each entry <ItemWithTags> receives the same entry.item object as before. No re-rendering, everything works as it should.

We can even show the item with one first assigned tag and avoid re-rendering:

<ItemWithTags
  v-for="entry in itemsWithTags"
  :key="entry.id"
  :item="entry.item"
  :first-tag="entry.tags.length ? entry.tags[0] : null"
/>

Once again - we refer to the same entry.item and the same entry.tags[0] objects as before. Here's the demo.

But how to handle all the tags in a correct way? If we do:

<ItemWithTags
  v-for="entry in itemsWithTags"
  :key="entry.id"
  :item="entry.item"
  :tags="entry.tags"
/>

then every new relation triggers every <ItemWithTags> to update. That happens because the getter constructs new entry.tags array for each item. One more demo is here.

The only working solution I found so far is to convert array to some primitive type using JSON.stringify (the last demo), but that's so ugly and not performant, that I'am eager to find a proper way. By proper I mean - we have a getter, some dependency says that it has to update. Then the update should be more selective. It should be possible to reuse some parts of the previous getter value while building the new one.

1 Answer 1

1

Well that is how computed properties in Vue work...

The update should be more selective. It should be possible to reuse some parts of the previous getter value while building the new one.

Not possible with computed properties. Only option is to manage the itemsWithTags "by hand"....create it, keep it in state and "selectively update" it when needed.

And btw is this real performance issue or just micro-optimalisation ? Because even though all components are updated, Vue virtual DOM diffing algorithm will update only the DOM of the affected component...

Another solution is to change your data structures:

  1. Instead of using itemTags, move the tags to each item - { id: 1, title: 'Item 1', tagIds: [1, 2, 5] } (this will of course make removing the tag completely from all items somehow harder)
  2. Resolve the tags inside the <ItemWithTags> component itself with computed prop mapping tagIds array into array of tag objects
  3. Change itemsWithTags getter - the part creating tags is no longer needed. It can now be simple itemsOrdered getter which returns original itemsByIds objects, just in order given by itemsOrder. As the getter does not create new objects anymore, change in one item (be it rename or adding/removing tag) will not trigger re-render of all components
getters = {
  itemsOrdered(state) {
    return state.itemsOrder.map(id => state.itemsByIds[id])
  }
}
5
  • Thank you for the exceptional answer. It gave a lot of thoughts. One is - why not? What if we do reuse some parts of the computed while recalculating? I created this tiny stupid package, npmjs.com/package/selective-object-reuse, then used it here, kasheftin.github.io/vue3-performance-tips/#/example7p1. Any thoughts? And - you idea about changing the structure - it will work for assigning, but it will not work for tag rename, so there's still a limitation.
    – Kasheftin
    Commented Mar 8, 2021 at 8:30
  • As to proposed change in data structure - the root of all problems is itemsWithTags getter and you can now rewrite it to minimize new objects creation. Updated my answer Commented Mar 8, 2021 at 9:14
  • As to your selective-object-reuse package - well you can do that sure, but now you are not using computed props as intended - keeping the real data outside. To be honest I'm not sold to the idea - you now have minimal number of re-renders but the trade-off is added complexity and deep object walk&compare after each data change. And it is really hard to tell if such tradeoff is worth it without proper measurements - to show re-rendering is a problem and to compare it with your new solution. Without it this is just premature optimization for me... Commented Mar 8, 2021 at 9:29
  • 1
    Also be aware that the way you use your selective-object-reuse in the example (\src\store\example7p1.js) you are creating global singleton. This can be serious and hard to debug problem in SSR environment (Nuxt etc) where the server can be dealing with multiple requests at the same time... Commented Mar 8, 2021 at 9:38
  • Well, for ssr it can be easily turned off. And it's not a singleton actually - it's just an object, if created in a component scope like mounted() { this.sor=new SOR() }, then should be disposed accordingly. Then, it's not keeping a computed outside, it's just keeping a reference to it outside, just because we don't have oldValue in computed generation (like we have in watcher). Your example works correctly for item rename. But it triggers every item re-rendering if any one tag renamed.
    – Kasheftin
    Commented Mar 8, 2021 at 10:58

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.