Skip to content

Local, server and infinite data ​

Person 1Engineering$50,0002015-01-01
Person 2Design$57,9192016-02-02
Person 3Sales$65,8382017-03-03
Person 4Support$73,7572018-04-04
Person 5Engineering$81,6762019-05-05
Person 6Design$89,5952020-06-06
Person 7Sales$97,5142021-07-07
Person 8Support$105,4332022-08-08
Person 9Engineering$113,3522023-09-09
Person 10Design$121,2712015-10-10
vue
<script setup lang="ts">
import { ref, shallowRef } from 'vue'
import {
  DataTable,
  filterRows,
  groupedSort,
  sortRows,
  useLocalDataSource,
  useServerDataSource,
  useTableState,
  type ColumnDef,
  type FetchParams,
  type FetchResult,
} from '@brillliand/vue-table-chad'
import '@brillliand/vue-table-chad/style.css'

type Person = { id: number; name: string; department: string; salary: number; hiredAt: string }

const columns: ColumnDef<Person>[] = [
  { id: 'name', header: 'Name', type: 'text', pinned: 'left' },
  { id: 'department', header: 'Department', type: 'enum', options: ['Engineering', 'Design', 'Sales', 'Support'] },
  {
    id: 'salary',
    header: 'Salary',
    type: 'number',
    align: 'right',
    format: (v) => (v == null ? '—' : `$${Number(v).toLocaleString()}`),
  },
  { id: 'hiredAt', header: 'Hired', type: 'date' },
]

function makePeople(count: number): Person[] {
  return Array.from({ length: count }, (_, i) => ({
    id: i + 1,
    name: `Person ${i + 1}`,
    department: ['Engineering', 'Design', 'Sales', 'Support'][i % 4]!,
    salary: 50_000 + ((i * 7919) % 90_000),
    hiredAt: new Date(2015 + (i % 9), i % 12, 1 + (i % 28)).toISOString().slice(0, 10),
  }))
}

const state = useTableState({ pageSize: 10 })

const rows = shallowRef<Person[]>(makePeople(2000))
const local = useLocalDataSource(rows, columns, state.query)

/**
 * A stand-in server over the same 2000 rows, so switching sources below
 * shows the same table either way. It does what `GET /people?…` would: the
 * library's own filter and sort over the whole set, then one page of it.
 * Replace the body with a `fetch` — the contract is the same `query` in and
 * `{ rows, total }` out.
 */
async function fetchPeople({ query, signal }: FetchParams): Promise<FetchResult<Person>> {
  await new Promise((resolve) => setTimeout(resolve, 300))
  if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
  const matched = filterRows(rows.value, columns, query)
  const ordered = sortRows(matched, groupedSort(query.sort, query.groupBy), columns)
  const start = (query.page - 1) * query.pageSize
  return { rows: ordered.slice(start, start + query.pageSize), total: matched.length }
}

const server = useServerDataSource(fetchPeople, state.query)

const useServer = ref(false)
</script>

<template>
  <label>
    <input v-model="useServer" type="checkbox" />
    Fetch from the fake server instead of the local array
  </label>

  <!-- Same columns, same state, same DataTable markup either way — only the
       object bound to `source` changes. -->
  <DataTable :columns="columns" :source="useServer ? server : local" :state="state" />
</template>

Local data ​

ts
const source = useLocalDataSource(rows, columns, state.query, { debounceMs: 150 })

The pipeline is filter → sort → slice, each stage its own computed, so paging redoes neither of the first two and changing the sort does not redo the filter.

debounceMs (default 150) coalesces the global search only. Every keystroke otherwise re-filters and re-sorts the whole dataset synchronously on the input event — around 22ms of blocked main thread per character at 10k rows. A filter checkbox or a header click is one deliberate act and always lands at once; a delay there reads as a broken table rather than a smooth one. Clearing the box is instant too, since emptying it can only ever widen the result.

QueryState is unaffected: it records every keystroke as it happens, so a URL or a store mirroring the query stays truthful while only the filtering lags. Pass 0 to switch the debounce off entirely — the right choice for small datasets and for tests that assert on the next line.

Server data ​

ts
const source = useServerDataSource(
  ({ query, signal }) =>
    fetch(`/api/people?${new URLSearchParams({ q: JSON.stringify(query) })}`, { signal })
      .then((r) => r.json()),          // -> { rows, total }
  state.query,
  {
    debounceMs: 300,
    fetchFacets: (columnId, { query, signal }) =>
      fetch(`/api/people/facets?column=${columnId}&q=${encodeURIComponent(JSON.stringify(query))}`,
        { signal }).then((r) => r.json()),
  },
)

What it handles for you:

  • Debouncing filter/search/sort changes, but never paging — clicking "next page" is instant.
  • Race conditions — a slow earlier response can never overwrite a fast later one (monotonic request ids + AbortController). There is an explicit test for this.
  • keepPreviousData so the table does not blank out between pages.
  • Facet scoping — the column's own filter is stripped before the facet request, so its checklist keeps offering the values you just unchecked.

Infinite data ​

ts
// `fetchPage` has the same signature as a server source's fetcher: `({ query, signal })`
// to `{ rows, total }`, with `query.page` and `query.pageSize` saying which portion.
const source = useInfiniteDataSource(fetchPage, state.query, { pageSize: 100 })
vue
<DataTable virtual :source="source" :end-threshold="10" @end-reached="source.loadMore" />

The same fetcher and the same surface as the server source. One thing is different, and everything else follows from it: a page adds to the list rather than replacing it.

  • It owns its paging. query.page and query.pageSize are ignored — virtual writes the dataset's length into the second one, and a source that read it would ask the server for everything at once. pageSize is an option of its own, defaulting to INFINITE_PAGE_SIZE.
  • rows and total are different numbers. rows is what has been loaded, total is what the server says matches; loaded and hasMore are the two derived from them. That is what makes the scrollbar grow as you go: it describes the list you have.
  • loadMore refuses to be asked twice. It is a no-op while a request is in flight and a no-op at the end of the list, which is what lets @end-reached be wired straight to it and fire as often as the window moves.
  • A filter, a search or a sort starts the list again, debounced. What "the next page" means changed with them.
  • initialLoading is the first page and loadingMore is every page after it — one blanks the table, the other should not.

end-threshold is how early the window asks, in rows. 0 waits until the last row is rendered; raise it and the request goes out while there are still rows to scroll through, which is what hides the latency of a slow server.

Scroll it. There is no pager, the counter climbs as pages arrive, and the scrollbar lengthens with the list you actually have:

0 of 0 loaded

Loading…
Loading…
vue
<script setup lang="ts">
import {
  DataTable,
  INFINITE_PAGE_SIZE,
  filterRows,
  groupedSort,
  sortRows,
  useInfiniteDataSource,
  useTableState,
  type ColumnDef,
  type FetchParams,
  type FetchResult,
} from '@brillliand/vue-table-chad'
import '@brillliand/vue-table-chad/style.css'

type Person = { id: number; name: string; department: string; salary: number; hiredAt: string }

const columns: ColumnDef<Person>[] = [
  { id: 'name', header: 'Name', type: 'text', pinned: 'left' },
  { id: 'department', header: 'Department', type: 'enum', options: ['Engineering', 'Design', 'Sales', 'Support'] },
  {
    id: 'salary',
    header: 'Salary',
    type: 'number',
    align: 'right',
    format: (v) => (v == null ? '—' : `$${Number(v).toLocaleString()}`),
  },
  { id: 'hiredAt', header: 'Hired', type: 'date' },
]

function makePeople(count: number): Person[] {
  return Array.from({ length: count }, (_, i) => ({
    id: i + 1,
    name: `Person ${i + 1}`,
    department: ['Engineering', 'Design', 'Sales', 'Support'][i % 4]!,
    salary: 50_000 + ((i * 7919) % 90_000),
    hiredAt: new Date(2015 + (i % 9), i % 12, 1 + (i % 28)).toISOString().slice(0, 10),
  }))
}

/**
 * A stand-in server — a promise and a delay, no network. The query it gets
 * carries the page the source wants next, so the same fetcher would serve a
 * paged table too; only what happens to the rows on arrival differs.
 */
const ALL = makePeople(10_000)
async function fetchPeople({ query, signal }: FetchParams): Promise<FetchResult<Person>> {
  await new Promise((resolve) => setTimeout(resolve, 300))
  if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
  const matched = filterRows(ALL, columns, query)
  const ordered = sortRows(matched, groupedSort(query.sort, query.groupBy), columns)
  const start = (query.page - 1) * query.pageSize
  return { rows: ordered.slice(start, start + query.pageSize), total: matched.length }
}

const state = useTableState({ pageSize: 25 })

/**
 * The rows accumulate instead of being replaced, so there is no pager: the
 * virtual window reaching the end of what is loaded is what asks for more.
 * `loadMore` is guarded, so wiring `@end-reached` straight to it is safe
 * however often the window fires.
 */
const source = useInfiniteDataSource(fetchPeople, state.query, { pageSize: INFINITE_PAGE_SIZE })
</script>

<template>
  <p>
    <strong>{{ source.loaded.value.toLocaleString() }}</strong> of
    {{ source.total.value.toLocaleString() }} loaded
    <span v-if="source.loadingMore.value">— fetching the next page…</span>
    <span v-else-if="!source.hasMore.value">— that is all of them.</span>
  </p>

  <DataTable
    :columns="columns"
    :source="source"
    :state="state"
    virtual
    :end-threshold="10"
    :show-pagination="false"
    @end-reached="source.loadMore()"
  />
</template>

Exporting the result set ​

ts
import { exportRows, toDelimited } from '@brillliand/vue-table-chad'
vue
<DataTable show-export export-filename="employees.csv" :source="source" :columns="columns" />

The button writes every filtered row, in sort order — not the page, and not the selection. A page is a viewport; a file that held only what was on screen would be a bug report waiting to happen.

  • toDelimited(rows, columns, options) is the pure half: rows in, delimited text out, RFC 4180 quoted. delimiter (',' by default, '\t' for a TSV), header, formatted and columnIds. It writes what the cells show, format included, so the file matches the screen — pass formatted: false when the file is going to a machine instead.
  • exportRows(source, columns, options) is the same thing over a data source. A local source already holds the whole filtered, sorted set in filteredRows, so it needs nothing else.
  • A server or infinite source needs exportFetchAll. Only you know how to ask your server for the whole result set rather than a page; without it the export holds the current page and says so in the console. On DataTable that is a prop, on exportRows an option called fetchAll.

The export event fires before the download and can replace it:

vue
<DataTable
  show-export
  @export="(payload) => { payload.preventDefault(); upload(payload.text) }"
/>

Leave preventDefault uncalled and the browser downloads the file, so the button works with nothing wired. The download itself is downloadText(filename, text), exported for the same reason — it is the preset's, because it touches the DOM, and toDelimited stays callable from a worker or a Node script.


Live: the Server data tab of pnpm demo (#server), and Infinite scroll (#infinite). Back to the docs index.