Vue 2 - v-for rendering with key not reusing elements

I’m facing an issue with v-for rendering in Vue 2.

Here is a simplified snippet of what I’m working on, a light virtual scroll table. (Codepen) :

<template>
  <div @wheel.prevent="onWheel">
    <table>
      <tr v-for="item of visibleItems"
          v-bind:key="item.id">
        <td>{{ item.id }}</td>
        <td><input :value="item.label"/></td>
      </tr>
    </table>
  </div>
</template>

<script>
export default {
  data: {
    start: 0,
    count: 10,
    items: [...Array(100).keys()]
      .map((i) => i + 1)
      .map((i) => ({
        id: i,
        label: `Item ${i}`,
      })),
    visibleItems: [],
  },
  methods: {
    onWheel(event) {
      if (event.deltaY < 0) {
        this.onScrollUp()
      } else if (event.deltaY > 0) {
        this.onScrollDown()
      }
    },
    onScrollUp() {
      console.log('up')
      const index = this.start - 1
      if (index < 0) return
      this.start = index
      // remove last item
      this.visibleItems.pop()
      // add first item
      this.visibleItems.unshift(this.items[this.start])
    },
    onScrollDown() {
      console.log('down')
      const index = this.start + 1
      if (index >= (this.items.length - this.count)) return
      this.start = index
      // remove first item
      this.visibleItems.shift()
      // add last item
      this.visibleItems.push(this.items[this.start + this.count])
    },
  },
  mounted() {
    this.visibleItems = this.items.slice(0, this.count)
  }
};
</script>

When I scroll up on the table, only the items not already shown are rendered, but when I scroll down, all items are rendered.

The thing is, I have inputs in the second column, when one is focused and I scroll up, the focus remains, but when I scroll down, the input loose it’s focus because the row is rendered.

How could I tell Vue to render only the new rows when I scroll down ?

To only render new rows when scrolling down, you can use v-if and a computed property to conditionally render only the newly added row.

Add a computed property to check the difference between the current visible items and the items that are going to be shown, then you can use v-if to conditionally render only the newly added row.

Here’s an example:

<template>
  <div @wheel.prevent="onWheel">
    <table>
      <tr v-for="item of visibleItems"
          v-bind:key="item.id"
          v-if="shouldRender(item)">
        <td>{{ item.id }}</td>
        <td><input :value="item.label"/></td>
      </tr>
    </table>
  </div>
</template>

<script>
export default {
  data: {
    start: 0,
    count: 10,
    items: [...Array(100).keys()]
      .map((i) => i + 1)
      .map((i) => ({
        id: i,
        label: `Item ${i}`,
      })),
    visibleItems: [],
    previousVisibleItems: [],
  },
  computed: {
    difference() {
      return this.visibleItems.filter(item => !this.previousVisibleItems.includes(item));
    },
  },
  methods: {
    onWheel(event) {
      if (event.deltaY < 0) {
        this.onScrollUp()
      } else if (event.deltaY > 0) {
        this.onScrollDown()
      }
    },
    onScrollUp() {
      console.log('up')
      const index = this.start - 1
      if (index < 0) return
      this.start = index
      this.previousVisibleItems = this.visibleItems.slice();
      // remove last item
      this.visibleItems.pop()
      // add first item
      this.visibleItems.unshift(this.items[this.start])
    },
    onScrollDown() {
      console.log('down')
      const index = this.start + 1
      if (index >= (this.items.length - this.count)) return
      this.start = index
      this.previousVisibleItems = this.visibleItems.slice();
      // remove first item
      this.visibleItems.shift()
      // add last item
      this.visibleItems.push(this.items[this.start + this.count])
    },
    shouldRender(item) {
      return this.difference.includes(item);
    },
  },
  mounted() {
    this.visibleItems = this.items.slice(0, this.count)
  }
};
</script>

This way only the newly added row will be rendered, keeping the focus on the input of the previously rendered row.

Thanks for your reply, but it doesn’t answer the problem.

The issue is about performance : the number of displayed rows must not change on scroll. But, when you look at the developer console, when you scroll up, you can see that only the new rows are rendered (they are highlighted in the DOM inspector tab), the others are reused. But while scrolling down, all the rows are rendered (= highlighted), not only the new one, meaning all rows are rendered and none are reused.

I’ve tested the same code in Vue 3 and it work as I expect.

Hello @crashfox
I have tried your code in my locally using Vue 2 and it’s working perfectly fine for me. Uncomment the one line in onScrollDown() method

onScrollDown() {
      console.log('down')
      const index = this.start + 1
      if (index >= (this.items.length - this.count)) return
      this.start = index
      // remove first item
      this.visibleItems.shift()
      // add last item
      this.visibleItems.push(this.items[this.start + this.count])
    },

Thanks @AddWeb_2012 for your answer, it was an auto-save of a test I did …

To illustrate what the problem is, you can focus in an input : while scrolling up, the input is still focused (because the row is reused), but while scrolling down, the input loose it’s focus (because of this annoying rendering I’m trying to avoid)

I did another codepen of the exact same code with Vue3, and it works as I intend to.