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} {view} source={{ processing: 'server', items, total }} />total counts every row matching the current view, not 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. You fetch and hand in each page (from a SvelteKit load, a store, a cache of your own), which is what
the rest of this page shows. Or you give the table a query function and it fetches for you: Query Function.
The tag is the same for both.
The tag is required, on every variant
Server processing takes controls away from the reader, so it is never inferred. A source that carriesitems and a total but no tag matches no variant and does
not compile; from plain JavaScript the table throws and names the tag it wanted.A few hundred 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 }) => {
const query = searchParamsToViewSnapshot(url.searchParams, userView);
return await fetchUsers(query); // { items, total }
};
// +page.svelte
<script lang="ts">
import { Table, createTableView } from '@urbicon-ui/table';
import { bindViewToUrl } from '@urbicon-ui/sveltekit-utils/url.svelte';
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 }} />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. The table renders both states; you say when they apply,
and whatever error holds is the message shown under the
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).
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);
observeView(view, async (snapshot) => {
loading = true;
try {
const result = await fetchUsers(snapshot);
items = result.items;
total = result.total;
error = null;
} catch (e) {
error = e instanceof Error ? e.message : 'Could not load users';
} finally {
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 exactly what
a query function receives, so the parameter translation works unchanged.
This fetches in the browser, the same as a query function does. What you get for the extra code is
the fetch itself: a refresh button, a poll, a cache in front of it.
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.
Search is not one of them: searchable gates the browser's own matcher, which server
processing has already switched off, and the search term reaches your endpoint either way.
Grouping is the one axis the table does not hand over. It travels as groupBy, and the table also buckets the rows that
come back — but only the rows of the current page, since those are the only ones it has. 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 rather than the group's size. 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 — a sum under a group of three is the sum of those three, not of the group. Their labels do not say so; if the 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.
Selection reaches as far as the loaded page. Select-all marks the rows the table holds, and onSelectionChange reports those. Ids from earlier
pages stay in state.selectedIds on the table context; their
rows are gone. For an action across the whole result set, send the query instead of the selection.