Skip to main content
Urbicon UI

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 carries items 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 to items 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

Server-side rendering all the way through — no fetch in the browser.
// 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

The table only aborts requests it started, so a superseded response here is yours to drop.
<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.