Vue Example: Infinite Scroll

vue
<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { useInfiniteQuery } from '@tanstack/vue-query'

const fetchServerPage = async (
  limit: number,
  offset: number = 0,
): Promise<{ rows: string[]; nextOffset: number }> => {
  const rows = new Array(limit)
    .fill(0)
    .map((e, i) => `Async loaded row #${i + offset * limit}`)

  await new Promise((r) => setTimeout(r, 500))

  return { rows, nextOffset: offset + 1 }
}

const {
  status,
  data,
  error,
  isFetching,
  isFetchingNextPage,
  fetchNextPage,
  hasNextPage,
} = useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: (ctx) => fetchServerPage(10, ctx.pageParam),
  getNextPageParam: (_lastGroup, groups) => groups.length,
})

const allRows = computed(() =>
  data.value ? data.value.pages.flatMap((d) => d.rows) : [],
)

const parentRef = ref<HTMLElement | null>(null)

const rowVirtualizerOptions = computed(() => {
  return {
    count: hasNextPage ? allRows.value.length + 1 : allRows.value.length,
    getScrollElement: () => parentRef.value,
    estimateSize: () => 100,
    overscan: 5,
  }
})

const rowVirtualizer = useVirtualizer(rowVirtualizerOptions)

const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems())

const totalSize = computed(() => rowVirtualizer.value.getTotalSize())

watchEffect(() => {
  const [lastItem] = [...virtualRows.value].reverse()

  if (!lastItem) {
    return
  }

  if (
    lastItem.index >= allRows.value.length - 1 &&
    hasNextPage.value &&
    !isFetchingNextPage.value
  ) {
    fetchNextPage()
  }
})
</script>

<template>
  <div>
    <p>
      This infinite scroll example uses Vue Query's useInfiniteScroll composable
      to fetch infinite data from a posts endpoint and then a rowVirtualizer is
      used along with a loader-row placed at the bottom of the list to trigger
      the next page to load.
    </p>

    <br />
    <br />
    <p v-if="status === 'loading'">Loading...</p>
    <p v-else-if="status === 'error'">Error: {{ (error as Error).message }}</p>
    <div
      v-else
      ref="parentRef"
      class="List"
      style="height: 500px; width: 100%; overflow: auto"
    >
      <div
        :style="{
          height: `${totalSize}px`,
          width: '100%',
          position: 'relative',
        }"
      >
        <div
          v-for="virtualRow in virtualRows"
          :key="virtualRow.key"
          :class="virtualRow.index % 2 ? 'ListItemOdd' : 'ListItemEven'"
          :style="{
            position: 'absolute',
            top: 0,
            left: 0,
            width: '100%',
            height: `${virtualRow.size}px`,
            transform: `translateY(${virtualRow.start}px)`,
          }"
        >
          <template v-if="virtualRow.index > allRows.length - 1">
            {{ hasNextPage ? 'Loading more...' : 'Nothing more to load' }}
          </template>
          <template v-else>
            {{ allRows[virtualRow.index] }}
          </template>
        </div>
      </div>
    </div>
    <div v-if="isFetching && !isFetchingNextPage">Background Updating...</div>
  </div>
</template>
<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { useInfiniteQuery } from '@tanstack/vue-query'

const fetchServerPage = async (
  limit: number,
  offset: number = 0,
): Promise<{ rows: string[]; nextOffset: number }> => {
  const rows = new Array(limit)
    .fill(0)
    .map((e, i) => `Async loaded row #${i + offset * limit}`)

  await new Promise((r) => setTimeout(r, 500))

  return { rows, nextOffset: offset + 1 }
}

const {
  status,
  data,
  error,
  isFetching,
  isFetchingNextPage,
  fetchNextPage,
  hasNextPage,
} = useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: (ctx) => fetchServerPage(10, ctx.pageParam),
  getNextPageParam: (_lastGroup, groups) => groups.length,
})

const allRows = computed(() =>
  data.value ? data.value.pages.flatMap((d) => d.rows) : [],
)

const parentRef = ref<HTMLElement | null>(null)

const rowVirtualizerOptions = computed(() => {
  return {
    count: hasNextPage ? allRows.value.length + 1 : allRows.value.length,
    getScrollElement: () => parentRef.value,
    estimateSize: () => 100,
    overscan: 5,
  }
})

const rowVirtualizer = useVirtualizer(rowVirtualizerOptions)

const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems())

const totalSize = computed(() => rowVirtualizer.value.getTotalSize())

watchEffect(() => {
  const [lastItem] = [...virtualRows.value].reverse()

  if (!lastItem) {
    return
  }

  if (
    lastItem.index >= allRows.value.length - 1 &&
    hasNextPage.value &&
    !isFetchingNextPage.value
  ) {
    fetchNextPage()
  }
})
</script>

<template>
  <div>
    <p>
      This infinite scroll example uses Vue Query's useInfiniteScroll composable
      to fetch infinite data from a posts endpoint and then a rowVirtualizer is
      used along with a loader-row placed at the bottom of the list to trigger
      the next page to load.
    </p>

    <br />
    <br />
    <p v-if="status === 'loading'">Loading...</p>
    <p v-else-if="status === 'error'">Error: {{ (error as Error).message }}</p>
    <div
      v-else
      ref="parentRef"
      class="List"
      style="height: 500px; width: 100%; overflow: auto"
    >
      <div
        :style="{
          height: `${totalSize}px`,
          width: '100%',
          position: 'relative',
        }"
      >
        <div
          v-for="virtualRow in virtualRows"
          :key="virtualRow.key"
          :class="virtualRow.index % 2 ? 'ListItemOdd' : 'ListItemEven'"
          :style="{
            position: 'absolute',
            top: 0,
            left: 0,
            width: '100%',
            height: `${virtualRow.size}px`,
            transform: `translateY(${virtualRow.start}px)`,
          }"
        >
          <template v-if="virtualRow.index > allRows.length - 1">
            {{ hasNextPage ? 'Loading more...' : 'Nothing more to load' }}
          </template>
          <template v-else>
            {{ allRows[virtualRow.index] }}
          </template>
        </div>
      </div>
    </div>
    <div v-if="isFetching && !isFetchingNextPage">Background Updating...</div>
  </div>
</template>
Subscribe to Bytes

Your weekly dose of JavaScript news. Delivered every Monday to over 100,000 devs, for free.

Bytes

No spam. Unsubscribe at any time.

Subscribe to Bytes

Your weekly dose of JavaScript news. Delivered every Monday to over 100,000 devs, for free.

Bytes

No spam. Unsubscribe at any time.