Cursor-based infinite scroll

Sparse-load a 100k-row audit log without paginating. Pattern: debounce on scroll, fetch the next chunk by cursor, splice into $state.

<script lang="ts">
  import { SvGrid, type SvGridApi } from 'sv-grid-community'

  type Event = { id: string; ts: string; actor: string; action: string }
  let rows = $state<Event[]>([])
  let cursor = $state<string | null>(null)
  let loading = $state(false)
  let exhausted = $state(false)

  async function loadMore() {
    if (loading || exhausted) return
    loading = true
    try {
      const r = await fetch(`/api/events?cursor=${cursor ?? ''}&limit=200`)
      const { items, next } = await r.json() as { items: Event[]; next: string | null }
      rows = [...rows, ...items]
      cursor = next
      if (!next || items.length === 0) exhausted = true
    } finally {
      loading = false
    }
  }

  $effect(() => { void loadMore() })

  /** Wire the wrapper's scroll container to trigger near the end. */
  function onScroll(el: HTMLElement) {
    if (el.scrollTop + el.clientHeight >= el.scrollHeight - 400) loadMore()
  }
</script>

<div onscroll={(e) => onScroll(e.currentTarget as HTMLElement)}
     style="height: 100%; overflow: auto;">
  <SvGrid data={rows} columns={columns} features={features}
    showPagination={false} virtualization={true} containerHeight="100%" />
  {#if loading}<p>Loading more…</p>{/if}
  {#if exhausted}<p>End of log.</p>{/if}
</div>

Why cursor + not page number

For append-only logs (audit, time-series), page numbers shift underneath you as new events arrive. Cursor (server-issued opaque token) is stable.

Cancellation

When the user filters mid-scroll, abort the in-flight fetch:

let controller: AbortController | null = null

async function loadMore() {
  controller?.abort()
  controller = new AbortController()
  const r = await fetch(`...`, { signal: controller.signal })
  ...
}

See also