Vue Example: Virtualized Infinite Scrolling

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import {
  FlexRender,
  columnSizingFeature,
  createColumnHelper,
  createSortedRowModel,
  rowSortingFeature,
  sortFns,
  tableFeatures,
  useTable,
} from '@tanstack/vue-table'
import { keepPreviousData, useInfiniteQuery } from '@tanstack/vue-query'
import { createAtom, useSelector } from '@tanstack/vue-store'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { fetchData } from './makeData'
import type { ComponentPublicInstance } from 'vue'
import type { Person, PersonApiResponse } from './makeData'

const fetchSize = 50
const isDev = import.meta.env.DEV

const features = tableFeatures({
  columnSizingFeature,
  rowSortingFeature,
  sortedRowModel: createSortedRowModel(),
  sortFns,
})

const columnHelper = createColumnHelper<typeof features, Person>()

const columns = columnHelper.columns([
  columnHelper.accessor('id', {
    header: 'ID',
    size: 60,
  }),
  columnHelper.accessor('firstName', {
    cell: (info) => info.getValue(),
  }),
  columnHelper.accessor((row) => row.lastName, {
    id: 'lastName',
    cell: (info) => info.getValue(),
    header: () => 'Last Name',
  }),
  columnHelper.accessor('age', {
    header: () => 'Age',
    size: 50,
  }),
  columnHelper.accessor('visits', {
    header: () => 'Visits',
    size: 50,
  }),
  columnHelper.accessor('status', {
    header: 'Status',
  }),
  columnHelper.accessor('progress', {
    header: 'Profile Progress',
    size: 80,
  }),
  columnHelper.accessor('createdAt', {
    header: 'Created At',
    cell: (info) => info.getValue<Date>().toLocaleString(),
    size: 200,
  }),
])

const sortingAtom = createAtom<Array<any>>([])
const sorting = useSelector(sortingAtom, (state) => state)

const tableContainerRef = ref<HTMLDivElement | null>(null)

const query = useInfiniteQuery<PersonApiResponse>(() => ({
  queryKey: ['people', sorting.value],
  queryFn: async ({ pageParam = 0 }) => {
    const start = (pageParam as number) * fetchSize
    return fetchData(start, fetchSize, sorting.value)
  },
  initialPageParam: 0,
  getNextPageParam: (
    _lastGroup: PersonApiResponse,
    groups: Array<PersonApiResponse>,
  ) => groups.length,
  refetchOnWindowFocus: false,
  placeholderData: keepPreviousData,
}))

const flatData = computed<Array<Person>>(
  () => query.data.value?.pages.flatMap((page) => page.data) ?? [],
)

const totalDBRowCount = computed(
  () => query.data.value?.pages[0]?.meta?.totalRowCount ?? 0,
)

const totalFetched = computed(() => flatData.value.length)

function fetchMoreOnBottomReached(containerRefElement?: HTMLDivElement | null) {
  if (!containerRefElement) {
    return
  }

  const { scrollHeight, scrollTop, clientHeight } = containerRefElement

  if (
    scrollHeight - scrollTop - clientHeight < 500 &&
    !query.isFetching.value &&
    totalFetched.value < totalDBRowCount.value
  ) {
    void query.fetchNextPage()
  }
}

onMounted(() => {
  fetchMoreOnBottomReached(tableContainerRef.value)
})

const table = useTable({
  features,
  get data() {
    return flatData.value
  },
  columns,
  atoms: {
    sorting: sortingAtom,
  },
  manualSorting: true,
  debugTable: true,
})

const rows = computed(() => table.getRowModel().rows)

const rowVirtualizer = useVirtualizer(
  computed(() => ({
    count: rows.value.length,
    estimateSize: () => 33,
    getScrollElement: () => tableContainerRef.value,
    measureElement:
      typeof window !== 'undefined' && !navigator.userAgent.includes('Firefox')
        ? (element: Element) => element.getBoundingClientRect().height
        : undefined,
    overscan: 5,
  })),
)

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

function measureRowElement(element: Element | ComponentPublicInstance | null) {
  if (!element || !(element instanceof Element)) {
    return
  }

  rowVirtualizer.value.measureElement(element)
}
</script>

<template>
  <div class="app">
    <p v-if="isDev">
      <strong>Notice:</strong> You are currently running Vue in development
      mode. Virtualized rendering performance will be slightly degraded until
      this application is built for production.
    </p>

    ({{ totalFetched.toLocaleString() }} of
    {{ totalDBRowCount.toLocaleString() }} rows fetched)

    <div
      ref="tableContainerRef"
      class="container"
      @scroll="fetchMoreOnBottomReached($event.currentTarget as HTMLDivElement)"
      :style="{
        overflow: 'auto',
        position: 'relative',
        height: '600px',
      }"
    >
      <table :style="{ display: 'grid' }">
        <thead
          :style="{
            display: 'grid',
            position: 'sticky',
            top: '0px',
            zIndex: 1,
          }"
        >
          <tr
            v-for="headerGroup in table.getHeaderGroups()"
            :key="headerGroup.id"
            :style="{ display: 'flex', width: '100%' }"
          >
            <th
              v-for="header in headerGroup.headers"
              :key="header.id"
              :style="{
                display: 'flex',
                width: `${header.getSize()}px`,
              }"
            >
              <div
                :class="{
                  'sortable-header': header.column.getCanSort(),
                }"
                @click="header.column.getToggleSortingHandler()?.($event)"
              >
                <FlexRender :header="header" />
                <span v-if="header.column.getIsSorted() === 'asc'"> 🔼</span>
                <span v-else-if="header.column.getIsSorted() === 'desc'">
                  🔽
                </span>
              </div>
            </th>
          </tr>
        </thead>

        <tbody
          :style="{
            display: 'grid',
            height: `${rowVirtualizer.getTotalSize()}px`,
            position: 'relative',
          }"
        >
          <tr
            v-for="virtualRow in virtualRows"
            :key="rows[virtualRow.index]?.id"
            :data-index="virtualRow.index"
            :ref="measureRowElement"
            :style="{
              display: 'flex',
              position: 'absolute',
              transform: `translateY(${virtualRow.start}px)`,
              width: '100%',
            }"
          >
            <td
              v-for="cell in rows[virtualRow.index]?.getAllCells() ?? []"
              :key="cell.id"
              :style="{
                display: 'flex',
                width: `${cell.column.getSize()}px`,
              }"
            >
              <FlexRender :cell="cell" />
            </td>
          </tr>
        </tbody>
      </table>
    </div>

    <div v-if="query.isFetching.value">Fetching More...</div>
  </div>
</template>