Skip to main content
Urbicon UI

Query Function

Give source a query function and the table calls your backend itself, whenever the reader sorts, filters or pages.

Letting the table fetch

A query function is the second way to run server processing: instead of fetching each page yourself, you hand the table a function and it calls that function whenever the reader changes what they see.

A table that fetches its own pages

The table builds its pager from the total the call returns.
<script lang="ts">
  import { Table, type Column, type TablePage, type TableViewSnapshot } from '@urbicon-ui/table';

  const columns: Column[] = [
    { accessor: 'name', title: 'Name', sortable: true },
    { accessor: 'team', title: 'Team', sortable: true }
  ];

  async function loadUsers(
    view: TableViewSnapshot,
    { signal }: { signal: AbortSignal }
  ): Promise<TablePage> {
    const params = new URLSearchParams({
      page: String(view.page),
      size: String(view.pageSize),
      q: view.search
    });
    // the backend sorts too — the table renders your page as you return it
    if (view.sort) params.set('sort', `${view.sort.column}:${view.sort.direction}`);
    const response = await fetch(`/api/users?${params}`, { signal });
    // fetch resolves for a 500; throw to reach the table's error state
    if (!response.ok) throw new Error(`Users request failed: ${response.status}`);
    return await response.json(); // { items, total }
  }
</script>

<Table {columns} source={{ processing: 'server', query: loadUsers }} viewDefaults={{ pageSize: 25 }} />

The table calls query once on mount, with the view's defaults, and again on every change. It renders the rows you return in the order you return them: searching, filtering, sorting and paging happen in your backend from here on.

While a request is open the table shows its loading state, and a rejected promise puts it into the error state with the rejection's message. You render neither yourself.

Demo

The demo runs against an in-memory list of 56 users, behind a delay you can set. Its query searches, sorts and pages that list the way your backend would, then returns { items, total } once the delay is up.

Simulated backend

Requests: 0 · Matching rows:

Idle
Name
Role
Team
Joined
Loading data...

Loading data...

Loading data...

Sort a column, change the page, or search (try “ada”, or “zz” for the empty state). Every interaction issues a fresh request; a fast typist still issues only one, because the search waits 300 ms and the fetch another 300 on top. Set the latency to 1.2 s and click through three sorts to watch requests supersede each other: the counter climbs, only the newest response renders.

From the view to your parameters

query receives two arguments: the view, holding the six settings the reader's controls write, and the signal for that request. Turn the view into whatever your endpoint reads:

Translating the view into your parameters

All six settings as URL parameters.
import type { TableViewSnapshot } from '@urbicon-ui/table';

function toParams(view: TableViewSnapshot) {
  const params = new URLSearchParams({
    page: String(view.page), // 1-based
    size: String(view.pageSize),
    q: view.search // exactly what was typed, spaces and all
  });

  // sort is null while nothing is sorted — no column, no direction
  if (view.sort) {
    params.set('sort', view.sort.column);
    params.set('dir', view.sort.direction); // 'asc' | 'desc'
  }

  if (view.groupBy) params.set('group', view.groupBy);

  // one filter: { column: 'status', operator: 'equals', value: 'active' }
  for (const filter of view.filters) {
    params.append('filter', `${filter.column}:${filter.operator}:${filter.value}`);
  }

  return params;
}

sort.column is the column's id: a Column without an explicit id goes by its accessor string, and that is what the header sends. operator is one of contains, equals, startsWith, endsWith, greaterThan or lessThan, and value is always a string, whatever the column holds. In the browser the table reads greaterThan and lessThan as a number first and as a date second; match that if the same columns are also filtered client-side somewhere. The full type is TableViewSnapshot.

groupBy is the one setting the table still acts on itself: it buckets the rows you returned, page by page, and the group headers count what is on that page. Send it along so your backend can order the rows into pages that group cleanly.

Return a TablePage: { items, total }, where items is the page you were asked for and total counts every row matching the query, which is what the pager divides by pageSize. An empty result is { items: [], total: 0 } and the table shows its empty state. Give every row a stable id: it is what selection and live updates key on.

One request per burst

The first call goes out immediately, every later one after debounceMs (300 by default). A fast typist produces one request, not one per keystroke — as long as searchDebounceMs is unset, which is what leaves the typing to this debounce. Set it and the search field collapses the burst instead, at your value; sort, filter and paging keep collapsing here.

Search waits once

searchDebounceMs and debounceMs sit on different objects — <Table searchDebounceMs={100} /> versus source={{ processing: 'server', query: loadUsers, debounceMs: 100 }} — but against a query source they never add up: with searchDebounceMs set, the field serves the whole delay and the fetch skips debounceMssearchDebounceMs={0} sends the request at once. It is the whole field, not just typing: Escape clears on the same clock as a backspace. What moves with the delay is the grouping: searchDebounceMs={150} against debounceMs: 800 sends one request per typing pause over 150 ms, each superseded one aborted. Every other view change — sort, filter, paging — waits debounceMs.

Pass the signal on

When a newer request supersedes one in flight, the table aborts it. Handing signal to fetch is what carries that abort to the network. Whatever an aborted request then resolves or rejects with is ignored, error state included, so a try/catch of your own cannot leak it into the table.

What it will not do

A change to the view is the only thing that starts a fetch. Re-applying a setting the view already holds is not a change, so query cannot ask the server the same question twice, and a failed request goes out again when the reader next changes something, not before.

A refresh button, a poll, a cache, retries, a refetch after a mutation: that is a data layer, and yours will do it better than one grown inside a table. TanStack Query and SvelteKit's remote functions both already have one. Hand its result to the manual flow, which asks only for rows and a total: Server Processing.

Rows changing under the reader are the third case. Push them into the table rather than refetching: Live Updates.