Table Workflows
Server data
In manual mode the table renders the page the server returned and reports the state the user asked for. The transport stays with the application; a small entry point sequences requests so a slow response never overwrites a newer one.
- Fixture
- Benchmark
On this page
manual switches the engine off for sorting, filtering, search and slicing: rows are
rendered as given, and rowCount gives the page count and the status line. Every
control still writes the state, so the application sends sorting, filters, search, pageIndex and pageSize to its
endpoint in whatever shape it uses. The table does not know what a request is.
createDataSource() (369 B gzip) wraps a loader the application supplies. Each request aborts the previous one through an AbortSignal and carries a sequence number, so even a transport that ignores the
signal cannot deliver stale rows. The snapshot it emits maps onto the table's loading, fetching and error props; existing rows stay visible
while a refresh is in flight.
import { createDataSource } from '@svelte-lean/table/server';
const source = createDataSource<Member>(
// The loader receives the complete TableState and an AbortSignal.
async (state, signal) => {
const response = await fetch(`/api/members?${toQuery(state)}`, { signal });
const { rows, total } = await response.json();
return { rows, total };
},
(snapshot) => (view = snapshot) // { rows, total, loading, error }
);
source.request(state); // aborts the previous request; a late response is ignored
source.dispose(); // on unmountManual mode
Control loading, errors, row counts and stale requests without coupling the table to a transport.
<script lang="ts">
import { untrack } from 'svelte';
import { DataTable, defaultState, type TableState } from '@svelte-lean/table';
import { createDataSource, type ServerSnapshot } from '@svelte-lean/table/server';
import { memberColumns, pick } from '$lib/demo/columns';
import { people, type Person } from '$lib/demo/data';
const columns = pick(memberColumns(), ['name', 'team', 'region', 'revenue']);
let tableState = $state<Partial<TableState>>(defaultState({ pageSize: 8 }));
let snapshot = $state<ServerSnapshot<Person>>({ rows: [], total: 0, loading: false, error: null });
let failNext = $state(false);
// The loader is the application's transport; here a delayed slice of the sample rows.
const source = createDataSource<Person>(
async (state, signal) => {
await new Promise((resolve) => setTimeout(resolve, 400));
if (signal.aborted) throw new Error('aborted');
if (failNext) { failNext = false; throw new Error('The example endpoint returned 503.'); }
const all = people(240);
const start = state.pageIndex * state.pageSize;
return { rows: all.slice(start, start + state.pageSize), total: all.length };
},
(next) => (snapshot = next)
);
// Every state change is a request; stale responses are dropped by the sequence guard.
// Only tableState is tracked: the request itself runs untracked so its writes cannot re-run the effect.
$effect(() => {
const state = defaultState(tableState);
untrack(() => source.request(state));
});
$effect(() => () => source.dispose());
</script>
<button type="button" data-slean="button" data-variant="outline" onclick={() => { failNext = true; source.request(defaultState(tableState)); }}>
Fail next request
</button>
<DataTable
manual
data={snapshot.rows}
rowCount={snapshot.total}
{columns}
getRowId={(row) => row.id}
bind:state={tableState}
loading={snapshot.loading && !snapshot.rows.length}
fetching={snapshot.loading && snapshot.rows.length > 0}
error={snapshot.error ?? undefined}
onRetry={() => source.request(defaultState(tableState))}
label="Members: server data"
/>- The prerendered page shows the loading status; the first request runs after hydration.
loadingis used while there are no rows,fetchingwhile rows are visible.- The sort buttons still toggle the state in manual mode; the order is the server's job.
Unknown total and debounced search
Page through an endpoint that does not count, and search on the server without a request per keystroke.
<script lang="ts">
import { untrack } from 'svelte';
import { DataTable, defaultState, type TableState } from '@svelte-lean/table';
import { memberColumns, pick } from '$lib/demo/columns';
import { people, type Person } from '$lib/demo/data';
const columns = pick(memberColumns(), ['name', 'team', 'region', 'revenue']);
const ALL = people(64);
let tableState = $state<Partial<TableState>>(defaultState({ pageSize: 6 }));
let rows = $state<Person[]>([]);
let hasMore = $state(false);
let loading = $state(true);
let requests = $state(0);
/** The example endpoint: one page and whether another one follows, never a total. */
async function fetchPage(state: TableState): Promise<{ rows: Person[]; hasMore: boolean }> {
await new Promise((resolve) => setTimeout(resolve, 350));
const term = state.search.trim().toLowerCase();
const matching = term ? ALL.filter((row) => row.name.toLowerCase().includes(term)) : ALL;
const start = state.pageIndex * state.pageSize;
return {
rows: matching.slice(start, start + state.pageSize),
hasMore: start + state.pageSize < matching.length
};
}
let latest = 0;
$effect(() => {
const state = defaultState(tableState);
untrack(() => {
const ticket = ++latest;
requests++;
loading = true;
fetchPage(state).then((page) => {
if (ticket !== latest) return;
rows = page.rows;
hasMore = page.hasMore;
loading = false;
});
});
});
const pageIndex = $derived(tableState.pageIndex ?? 0);
</script>
<DataTable
data={rows}
{columns}
getRowId={(row) => row.id}
bind:state={tableState}
manual
pageCount={pageIndex + (hasMore ? 2 : 1)}
pageSizeOptions={[6, 12, 24]}
searchable
searchDelay={300}
loading={loading && !rows.length}
fetching={loading && rows.length > 0}
skeletonRows={6}
label="Members: unknown total"
/>
<p class="meta">{requests} requests</p>searchDelayalso applies to the filter inputs offilters; the state, and with it every request, changes only after the pause.- Without
searchablethe engine still appliesstate.search: a search box elsewhere in the page (a card header, a toolbar) writes the state, and the table follows.
Large pages
Rows stay in the document; nothing is virtualized, so find-in-page, printing and assistive
technology reach every row. A page of hundreds of rows arriving from the server is still one
large DOM update. With progressive (true for one screenful, or a batch
size), a change that brings at least a batch of rows the table has not rendered yet renders the
first batch at once and the rest in slices sized from the measured cost of a row, one macrotask
apart, so input and paint are never blocked by the whole page. For hundreds of rows, add layout="grid".
- Server output and its hydration are always complete; a table first rendered in the browser mounts progressively too.
- Sorting, filtering or searching rows already on screen renders at once; only new rows are sliced.
- Row callbacks receive the row's
indexon the page:rowClass(row, index),onRowClick(row, event, index),onAction(action, row, trigger, index).
Loading, error and empty states
Make waiting, retrying and zero-result states part of the component contract.
Amara Okafor | Engineering | Europe | $4,501 |
Jonas Lindqvist | Design | Americas | $12,420 |
Mei Tanaka | Operations | Asia | $20,339 |
Rafael Duarte | Growth | Europe | $28,258 |
<script lang="ts">
import { DataTable } from '@svelte-lean/table';
import { memberColumns, pick } from '$lib/demo/columns';
import { people } from '$lib/demo/data';
const columns = pick(memberColumns(), ['name', 'team', 'region', 'revenue']);
let rows = $state(people(4));
let loading = $state(false);
let fetching = $state(false);
let error = $state('');
</script>
{#snippet empty()}
<strong>No matching members</strong>
<span>Change the filters or restore the sample rows.</span>
{/snippet}
<label><input type="checkbox" data-slean="checkbox" bind:checked={loading} /> loading</label>
<label><input type="checkbox" data-slean="checkbox" bind:checked={fetching} /> fetching</label>
<button type="button" data-slean="button" data-variant="outline" onclick={() => (rows = [])}>Empty</button>
<button type="button" data-slean="button" data-variant="outline" onclick={() => (error = 'The example endpoint returned 503.')}>Error</button>
<button type="button" data-slean="button" data-variant="outline" onclick={() => { rows = people(4); error = ''; }}>Restore</button>
<DataTable
data={rows}
{columns}
getRowId={(row) => row.id}
{loading}
{fetching}
error={error || undefined}
onRetry={() => (error = '')}
{empty}
skeletonRows={4}
pagination={false}
label="Members: loading and empty"
/>- Both loading props set
aria-busyon the table and announce theloadingmessage. loadingwithout rows drawsskeletonRowsplaceholder rows (default 5, hidden from assistive technology); the bars shimmer only when the user has not asked for reduced motion.skeletonRows={0}shows theloadingmessage instead.fetchingsetsdata-fetchingon the root; the stylesheet dims the body to--slean-table-fetching-opacity.
Empty states
Tell "nothing matches" from "nothing here" and offer the way back.
No matching members Nothing matches "nobody". | ||
<script lang="ts">
import { DataTable, defaultState, type EmptyContext, type TableState } from '@svelte-lean/table';
import { memberColumns, pick } from '$lib/demo/columns';
import { people } from '$lib/demo/data';
const columns = pick(memberColumns(), ['name', 'team', 'region']);
let data = $state(people(8));
let tableState = $state<Partial<TableState>>(
defaultState({ search: 'nobody', pageSize: 5 })
);
</script>
{#snippet empty({ search, filtered }: EmptyContext)}
<div class="empty">
{#if search || filtered}
<strong>No matching members</strong>
<span>Nothing matches {search ? `"${search}"` : 'the filters'}.</span>
<span class="actions">
{#if search}<button
type="button"
data-slean="button"
data-variant="outline"
data-size="sm"
onclick={() => (tableState = { ...tableState, search: '', pageIndex: 0 })}
>Clear search</button
>{/if}
{#if filtered}<button
type="button"
data-slean="button"
data-variant="outline"
data-size="sm"
onclick={() => (tableState = { ...tableState, filters: [], pageIndex: 0 })}
>Clear filters</button
>{/if}
</span>
{:else}
<strong>No members yet</strong>
<span>Invite the first member to see them here.</span>
<span class="actions"
><button
type="button"
data-slean="button"
data-size="sm"
onclick={() => (data = people(8))}>Restore sample rows</button
></span
>
{/if}
</div>
{/snippet}
<div class="controls">
<input
type="search"
data-slean="input"
aria-label="Search members"
placeholder="Search members"
value={tableState.search ?? ''}
oninput={(e) => (tableState = { ...tableState, search: e.currentTarget.value, pageIndex: 0 })}
/>
<button type="button" data-slean="button" data-variant="outline" onclick={() => (data = [])}>
Remove all rows
</button>
</div>
<DataTable
{data}
{columns}
getRowId={(row) => row.id}
bind:state={tableState}
filters
{empty}
label="Members: empty states"
/>API
| Name | Of | Type | Default | Description |
|---|---|---|---|---|
manual | Controlled state | boolean | false | Treats data as already sorted, filtered and paged by the application; the client model applies none of them. |
rowCount | Controlled state | number | undefined | Total row count in manual mode, for the page count, the status line and aria-rowcount. Without it the status line leaves the total out. |
loading | States | boolean | false | Announces the loading status and marks the table aria-busy; without rows it draws skeletonRows placeholder rows. |
fetching | States | boolean | false | Background refresh: status, aria-busy and data-fetching on the root (the body dims) while rows stay visible. |
error | States | string | undefined | Text of an alert rendered above the table. |
onRetry | States | () => void | undefined | Adds the retry button to the alert. |
empty | States | Snippet<[EmptyContext]> | undefined | Content of the single body cell when the row model has no rows. Receives search, filtered and loading to tell no matches from no data. |
skeletonRows | States | number | 5 | Placeholder rows drawn while loading without rows; 0 shows the loading message instead. |
pageCount | Controlled state | number | from rowCount | Manual mode: the page count when the total is unknown, for example pageIndex + 2 while the server reports a next page. |
searchDelay | Controls | number | 0 | Milliseconds between the last keystroke in the search box or a filter input and the state change; clearing a field applies at once. |
searchable | Controls | boolean | false | Shows the search input. state.search applies without it, so a search box elsewhere in the page can write the state. |
EmptyContext | Row model | { search: string; filtered: boolean; loading: boolean } | – | Argument of the empty snippet; filtered is true while a column filter holds a value. |
loading | TableMessages | string | 'Loading rows…' | Status text while loading or fetching. |
empty | TableMessages | string | 'No matching rows' | Body text when no row matches. |
retry | TableMessages | string | 'Retry' | Retry button in the error alert. |
@svelte-lean/table/server | Entry points | createDataSource | – | Sequencing and abort of a loader the application supplies. |