Docs
CodeRabbit
Cloudflare
AG Grid
SerpAPI
Netlify
OpenRouter
Neon
WorkOS
Clerk
Electric
PowerSync
Sentry
Railway
Prisma
Strapi
Unkey
CodeRabbit
Cloudflare
AG Grid
SerpAPI
Netlify
OpenRouter
Neon
WorkOS
Clerk
Electric
PowerSync
Sentry
Railway
Prisma
Strapi
Unkey
API Reference
Table API Reference
Column API Reference
Row API Reference
Cell API Reference
Header API Reference
Features API Reference
Enterprise

Svelte Example: Virtualized Infinite Scrolling

<script lang="ts">
  import {
    FlexRender,
    columnSizingFeature,
    createColumnHelper,
    createSortedRowModel,
    createTable,
    rowSortingFeature,
    sortFns,
    tableFeatures,
  } from '@tanstack/svelte-table'
  import {
    createInfiniteQuery,
    keepPreviousData,
  } from '@tanstack/svelte-query'
  import { createVirtualizer } from '@tanstack/svelte-virtual'
  import { get } from 'svelte/store'
  import { onMount } from 'svelte'
  import { fetchData } from './makeData'
  import type { Person, PersonApiResponse } from './makeData'
  import type { SortingState } from '@tanstack/svelte-table'
  import './index.css'

  const fetchSize = 50

  const _features = tableFeatures({ columnSizingFeature, rowSortingFeature })

  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,
    }),
  ])

  // Manage sorting state externally for the manual sorting + infinite query pattern
  let sorting = $state<SortingState>([])

  let tableContainerRef = $state<HTMLDivElement | undefined>(undefined)

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

  const flatData = $derived(
    query.data?.pages.flatMap((page: PersonApiResponse) => page.data) ?? [],
  )
  const totalDBRowCount = $derived(
    query.data?.pages[0]?.meta?.totalRowCount ?? 0,
  )
  const totalFetched = $derived(flatData.length)

  const fetchMoreOnBottomReached = (
    containerRefElement?: HTMLDivElement | null,
  ) => {
    if (containerRefElement) {
      const { scrollHeight, scrollTop, clientHeight } = containerRefElement
      if (
        scrollHeight - scrollTop - clientHeight < 500 &&
        !query.isFetching &&
        totalFetched < totalDBRowCount
      ) {
        void query.fetchNextPage()
      }
    }
  }

  // Check on mount to see if the table is already scrolled to the bottom and immediately needs to fetch more data
  onMount(() => {
    fetchMoreOnBottomReached(tableContainerRef)
  })

  const table = createTable({
    _features,
    _rowModels: { sortedRowModel: createSortedRowModel(sortFns) },
    get data() {
      return flatData
    },
    columns,
    state: {
      get sorting() {
        return sorting
      },
    },
    onSortingChange: (updater) => {
      sorting = typeof updater === 'function' ? updater(sorting) : updater
    },
    manualSorting: true,
    debugTable: true,
  })

  const rows = $derived(table.getRowModel().rows)

  // Important: The virtualizer and the scroll container ref must be in the same component scope.
  const rowVirtualizer = createVirtualizer<
    HTMLDivElement,
    HTMLTableRowElement
  >({
    get count() {
      return rows.length
    },
    estimateSize: () => 33,
    getScrollElement: () => tableContainerRef ?? null,
    measureElement:
      typeof window !== 'undefined' &&
      navigator.userAgent.indexOf('Firefox') === -1
        ? (element) => element.getBoundingClientRect().height
        : undefined,
    overscan: 5,
  })

  // When the container ref becomes available, update the virtualizer
  // so it picks up the scroll element and sets up scroll observers.
  $effect(() => {
    if (tableContainerRef) {
      get(rowVirtualizer).setOptions({
        getScrollElement: () => tableContainerRef ?? null,
      })
    }
  })

  // When row count changes (new pages fetched), push the new count to the virtualizer.
  // The svelte-virtual store adapter doesn't reactively track getter options.
  $effect(() => {
    get(rowVirtualizer).setOptions({ count: rows.length })
  })

  // Svelte action to measure dynamic row heights via the virtualizer
  function measureElement(node: HTMLTableRowElement) {
    get(rowVirtualizer).measureElement(node)
  }
</script>

<div class="app">
  {#if import.meta.env.DEV}
    <p>
      <strong>Notice:</strong> You are currently running Svelte in development mode.
      Virtualized rendering performance will be slightly degraded until this
      application is built for production.
    </p>
  {/if}
  ({totalFetched} of {totalDBRowCount} rows fetched)
  <div
    class="container"
    onscroll={(e) => fetchMoreOnBottomReached(e.currentTarget as HTMLDivElement)}
    bind:this={tableContainerRef}
    style="overflow: auto; position: relative; height: 600px;"
  >
    <table style="display: grid;">
      <thead style="display: grid; position: sticky; top: 0px; z-index: 1;">
        {#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
          <tr style="display: flex; width: 100%;">
            {#each headerGroup.headers as header (header.id)}
              <th style="display: flex; width: {header.getSize()}px;">
                <div
                  class={header.column.getCanSort()
                    ? 'cursor-pointer select-none'
                    : ''}
                  role="button"
                  tabindex="0"
                  onclick={header.column.getToggleSortingHandler()}
                  onkeydown={(e) => {
                    if (e.key === 'Enter' || e.key === ' ') {
                      header.column.getToggleSortingHandler()?.(e)
                    }
                  }}
                >
                  <FlexRender header={header} />
                  {#if header.column.getIsSorted() === 'asc'}
                    {' '}🔼
                  {:else if header.column.getIsSorted() === 'desc'}
                    {' '}🔽
                  {/if}
                </div>
              </th>
            {/each}
          </tr>
        {/each}
      </thead>
      <tbody
        style="display: grid; height: {$rowVirtualizer.getTotalSize()}px; position: relative;"
      >
        {#each $rowVirtualizer.getVirtualItems() as virtualRow (virtualRow.index)}
          {@const row = rows[virtualRow.index]}
          <tr
            data-index={virtualRow.index}
            use:measureElement
            style="display: flex; position: absolute; transform: translateY({virtualRow.start}px); width: 100%;"
          >
            {#each row.getAllCells() as cell (cell.id)}
              <td style="display: flex; width: {cell.column.getSize()}px;">
                <FlexRender cell={cell} />
              </td>
            {/each}
          </tr>
        {/each}
      </tbody>
    </table>
  </div>
  {#if query.isFetching}
    <div>Fetching More...</div>
  {/if}
</div>
<script lang="ts">
  import {
    FlexRender,
    columnSizingFeature,
    createColumnHelper,
    createSortedRowModel,
    createTable,
    rowSortingFeature,
    sortFns,
    tableFeatures,
  } from '@tanstack/svelte-table'
  import {
    createInfiniteQuery,
    keepPreviousData,
  } from '@tanstack/svelte-query'
  import { createVirtualizer } from '@tanstack/svelte-virtual'
  import { get } from 'svelte/store'
  import { onMount } from 'svelte'
  import { fetchData } from './makeData'
  import type { Person, PersonApiResponse } from './makeData'
  import type { SortingState } from '@tanstack/svelte-table'
  import './index.css'

  const fetchSize = 50

  const _features = tableFeatures({ columnSizingFeature, rowSortingFeature })

  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,
    }),
  ])

  // Manage sorting state externally for the manual sorting + infinite query pattern
  let sorting = $state<SortingState>([])

  let tableContainerRef = $state<HTMLDivElement | undefined>(undefined)

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

  const flatData = $derived(
    query.data?.pages.flatMap((page: PersonApiResponse) => page.data) ?? [],
  )
  const totalDBRowCount = $derived(
    query.data?.pages[0]?.meta?.totalRowCount ?? 0,
  )
  const totalFetched = $derived(flatData.length)

  const fetchMoreOnBottomReached = (
    containerRefElement?: HTMLDivElement | null,
  ) => {
    if (containerRefElement) {
      const { scrollHeight, scrollTop, clientHeight } = containerRefElement
      if (
        scrollHeight - scrollTop - clientHeight < 500 &&
        !query.isFetching &&
        totalFetched < totalDBRowCount
      ) {
        void query.fetchNextPage()
      }
    }
  }

  // Check on mount to see if the table is already scrolled to the bottom and immediately needs to fetch more data
  onMount(() => {
    fetchMoreOnBottomReached(tableContainerRef)
  })

  const table = createTable({
    _features,
    _rowModels: { sortedRowModel: createSortedRowModel(sortFns) },
    get data() {
      return flatData
    },
    columns,
    state: {
      get sorting() {
        return sorting
      },
    },
    onSortingChange: (updater) => {
      sorting = typeof updater === 'function' ? updater(sorting) : updater
    },
    manualSorting: true,
    debugTable: true,
  })

  const rows = $derived(table.getRowModel().rows)

  // Important: The virtualizer and the scroll container ref must be in the same component scope.
  const rowVirtualizer = createVirtualizer<
    HTMLDivElement,
    HTMLTableRowElement
  >({
    get count() {
      return rows.length
    },
    estimateSize: () => 33,
    getScrollElement: () => tableContainerRef ?? null,
    measureElement:
      typeof window !== 'undefined' &&
      navigator.userAgent.indexOf('Firefox') === -1
        ? (element) => element.getBoundingClientRect().height
        : undefined,
    overscan: 5,
  })

  // When the container ref becomes available, update the virtualizer
  // so it picks up the scroll element and sets up scroll observers.
  $effect(() => {
    if (tableContainerRef) {
      get(rowVirtualizer).setOptions({
        getScrollElement: () => tableContainerRef ?? null,
      })
    }
  })

  // When row count changes (new pages fetched), push the new count to the virtualizer.
  // The svelte-virtual store adapter doesn't reactively track getter options.
  $effect(() => {
    get(rowVirtualizer).setOptions({ count: rows.length })
  })

  // Svelte action to measure dynamic row heights via the virtualizer
  function measureElement(node: HTMLTableRowElement) {
    get(rowVirtualizer).measureElement(node)
  }
</script>

<div class="app">
  {#if import.meta.env.DEV}
    <p>
      <strong>Notice:</strong> You are currently running Svelte in development mode.
      Virtualized rendering performance will be slightly degraded until this
      application is built for production.
    </p>
  {/if}
  ({totalFetched} of {totalDBRowCount} rows fetched)
  <div
    class="container"
    onscroll={(e) => fetchMoreOnBottomReached(e.currentTarget as HTMLDivElement)}
    bind:this={tableContainerRef}
    style="overflow: auto; position: relative; height: 600px;"
  >
    <table style="display: grid;">
      <thead style="display: grid; position: sticky; top: 0px; z-index: 1;">
        {#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
          <tr style="display: flex; width: 100%;">
            {#each headerGroup.headers as header (header.id)}
              <th style="display: flex; width: {header.getSize()}px;">
                <div
                  class={header.column.getCanSort()
                    ? 'cursor-pointer select-none'
                    : ''}
                  role="button"
                  tabindex="0"
                  onclick={header.column.getToggleSortingHandler()}
                  onkeydown={(e) => {
                    if (e.key === 'Enter' || e.key === ' ') {
                      header.column.getToggleSortingHandler()?.(e)
                    }
                  }}
                >
                  <FlexRender header={header} />
                  {#if header.column.getIsSorted() === 'asc'}
                    {' '}🔼
                  {:else if header.column.getIsSorted() === 'desc'}
                    {' '}🔽
                  {/if}
                </div>
              </th>
            {/each}
          </tr>
        {/each}
      </thead>
      <tbody
        style="display: grid; height: {$rowVirtualizer.getTotalSize()}px; position: relative;"
      >
        {#each $rowVirtualizer.getVirtualItems() as virtualRow (virtualRow.index)}
          {@const row = rows[virtualRow.index]}
          <tr
            data-index={virtualRow.index}
            use:measureElement
            style="display: flex; position: absolute; transform: translateY({virtualRow.start}px); width: 100%;"
          >
            {#each row.getAllCells() as cell (cell.id)}
              <td style="display: flex; width: {cell.column.getSize()}px;">
                <FlexRender cell={cell} />
              </td>
            {/each}
          </tr>
        {/each}
      </tbody>
    </table>
  </div>
  {#if query.isFetching}
    <div>Fetching More...</div>
  {/if}
</div>