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
<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: —
Name | Role | Team | Joined |
|---|---|---|---|
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
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 afterdebounceMs (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 debounceMs — searchDebounceMs={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. Handingsignal 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.