Server Processing
Your backend sorts, filters and pages. You hand the table one page of rows at a time, and every control becomes a request.
Who does the work
processing: 'server' switches the table's own searching,
filtering, sorting and paging off. Your backend does that work, and the table renders the page
it is handed:
A page of rows, and how many there are
<Table {columns} source={{ processing: 'server', items, total }} />total counts every row your endpoint matched for the current
search and filters, not just the ones on this page. The pager divides it by the page size, so
it is what decides how far the reader can page.
There are two ways to run the fetch, and the tag is the same for both. A query function lets the table fetch for you whenever
the view changes: Query Function.
Fetching yourself and handing in each page (from a SvelteKit load, a store, a cache of your own) is what the rest
of this page shows; take it when something other than the view has to trigger a fetch too, a
refresh button or a poll.
source always carries the tag
Server processing takes controls away from the reader, so it is never inferred. A source ofitems and a total without the tag is a type error, and from
plain JavaScript the table throws and names the tag it wanted. The items prop needs none: rows on their own mean client processing.A few thousand rows need none of this
Pass them toitems and let the table sort and page them in the browser: Client Processing.Rows from a SvelteKit load
Put the view in the URL, and every sort, filter and page change becomes a navigation that load answers with the next page. The first page arrives
in the HTML the reader receives.
One round trip per interaction
// view-defaults.ts — one declaration, read by both halves
export const userView = { pageSize: 25 };
// +page.server.ts
import { searchParamsToViewSnapshot } from '@urbicon-ui/sveltekit-utils/table-view';
import { userView } from './view-defaults';
export const load = async ({ url }) => {
// { search, sort, page, pageSize, filters, groupBy }, resolved against userView
const snapshot = searchParamsToViewSnapshot(url.searchParams, userView);
return await fetchUsers(snapshot); // { items, total }
};
// +page.svelte
<script lang="ts">
import { Table, createTableView } from '@urbicon-ui/table';
import { bindViewToUrl } from '@urbicon-ui/sveltekit-utils/url.svelte';
import { navigating } from '$app/state';
import { userView } from './view-defaults';
import { columns } from './columns';
let { data } = $props();
const view = createTableView({ defaults: userView });
bindViewToUrl(view);
</script>
<Table
{columns}
{view}
source={{
processing: 'server',
items: data.items,
total: data.total,
loading: !!navigating.to
}}
/>The snapshot your fetch receives is the view itself, under the names the table uses: search (a string), sort ({ column, direction }, or null), page and pageSize, filters (each { column, operator, value }) and groupBy. Every column is a column id. Projecting those six onto your
backend's parameters happens inside your fetch, and parameter translation walks through one.
searchParamsToViewSnapshot reads the URL against the
same defaults you hand createTableView. Export those
defaults from one module, as above, and both halves resolve a missing parameter the same
way. Which parameters get written, and what else the server can read from them, is on URL State.
loading and error are yours here, because the fetch is. On this route the fetch is a navigation, and SvelteKit already
tracks it: navigating.to stays set for as long as load runs. A load that throws renders your error page instead, so error carries the failures you catch and return as data,
and whatever it holds is the message under the table's error heading.
Without the URL
Not every table wants its state in the address bar. Then observeView is what tells you to fetch. It fires once
after mount, then debounced after every change (300 ms, or { debounceMs } as a third argument to observeView).
Refetching without the URL
<script lang="ts">
import { Table, createTableView, observeView } from '@urbicon-ui/table';
const view = createTableView({ defaults: { pageSize: 25 } });
let items = $state<User[]>([]);
let total = $state(0);
let loading = $state(false);
let error = $state<string | null>(null);
let latest = 0;
observeView(view, async (snapshot) => {
const run = ++latest;
loading = true;
try {
const result = await fetchUsers(snapshot);
if (run !== latest) return; // a newer view has already asked
items = result.items;
total = result.total;
error = null;
} catch (e) {
if (run !== latest) return;
error = e instanceof Error ? e.message : 'Could not load users';
} finally {
if (run === latest) loading = false;
}
});
</script>
<Table {columns} {view} source={{ processing: 'server', items, total, loading, error }} />Call observeView during component initialisation,
next to createTableView; the subscription ends with
the component, so there is nothing to unsubscribe. The snapshot it hands you is the same
object the load above passes to its fetch, and the
same one a query function receives.
This fetches in the browser, so the first rows arrive after mount rather than in the HTML the reader receives.
What changes for the reader
Sorting, filtering, search and paging are requests now. The controls look and behave the
same way, but each one only changes the query, so a parameter your endpoint ignores is a
control that quietly does nothing. sortable: false and groupable: false on a column remove the affordances you
cannot serve.
The search field is the exception: its term travels whatever the columns say. searchable: false takes a column out of the browser's own
matcher, which server processing has already switched off, and out of the filter menu, whose filters
do reach your endpoint. So it stays the flag for a column your endpoint cannot filter on.
Grouping is the one axis the table does not hand over. It travels as groupBy, and the table buckets the rows that come
back, which are only the rows of the current page. The groups are therefore page-local: the
pager stays, each page is grouped on its own, and a group header counts this page's rows and
says so: its count reads (3 items on this page). Your endpoint can make those
groups more useful by ordering its result by groupBy, so a page holds whole groups instead of
slices of several. The order the groups appear in follows the page's rows, so it can differ
from page to page unless your endpoint imposes one.
Group summary rows aggregate the same page-local rows, so a sum under a group of three is the sum of those three, not of the group, and their labels do not say so. If that distinction matters to your readers, compute the totals server-side and render them yourself. Collapsing follows the group's name, so a group collapsed on one page stays collapsed on the next even though its rows are different.
Select-all marks the rows the table holds, which here is the loaded page. The selection
itself survives paging: a row ticked on page 1 is still ticked when the reader comes back,
and onSelectionChange does not fire on a page change.
This is why its second argument matters here — the ids are the whole selection, the rows
only the ones currently loaded, so a controlled selection writes the ids back. For an action across the whole result set, send the query instead of the
selection.