sveltelean Table
Versionv0.2.0 GitHub

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.

a data source
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 unmount

Manual mode

Control loading, errors, row counts and stale requests without coupling the table to a transport.

Server data
Requests: 0
Loading rows…
Page 1 of 1 · 0 rows

ServerData.svelte
<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 loader is a delayed slice of the sample rows; every state change (page, page size, sort) is a request, and the counter shows how many were made. Fail next request makes the following response an error with a Retry button; paging quickly shows the sequence guard: only the latest response renders.
  • The prerendered page shows the loading status; the first request runs after hydration.
  • loading is used while there are no rows, fetching while rows are visible.
  • The sort buttons still toggle the state in manual mode; the order is the server's job.

Page through an endpoint that does not count, and search on the server without a request per keystroke.

Unknown total
Loading rows…
Page 1 of 1

0 requests

UnknownTotal.svelte
<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>
The example endpoint returns one page and whether another follows. pageCount is the current page plus one while there is more, so Next and the numbered pages stop at the last page reached; the status line shows no total. searchDelay=300 writes state.search 300 milliseconds after the last keystroke, so typing a name is one request; clearing the box applies at once.
  • searchDelay also applies to the filter inputs of filters; the state, and with it every request, changes only after the pause.
  • Without searchable the engine still applies state.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 index on 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.

Loading and empty
Amara Okafor
Engineering
Europe
$4,501
Jonas Lindqvist
Design
Americas
$12,420
Mei Tanaka
Operations
Asia
$20,339
Rafael Duarte
Growth
Europe
$28,258

LoadingEmpty.svelte
<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"
/>
loading and fetching are native checkboxes; Empty, Error and Restore are Button primitives. loading without rows draws skeleton rows (Empty, then loading); fetching dims the rows it keeps. The status is announced through a polite live region, the error is an alert with the Retry button when onRetry is set, and an empty body renders the empty snippet or the empty message.
  • Both loading props set aria-busy on the table and announce the loading message.
  • loading without rows draws skeletonRows placeholder rows (default 5, hidden from assistive technology); the bars shimmer only when the user has not asked for reduced motion. skeletonRows={0} shows the loading message instead.
  • fetching sets data-fetching on 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.

Empty states
No matching members Nothing matches "nobody".
Page 1 of 1 · 0 rows

EmptyStates.svelte
<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"
/>
The empty snippet receives search, filtered and loading. With a search or an active column filter it offers Clear search and Clear filters, which write the state; with no data at all it shows a first-run message. The search box above the table is the Input primitive writing state.search; the table's own search box is off.

API

NameOfTypeDefaultDescription
manualControlled statebooleanfalseTreats data as already sorted, filtered and paged by the application; the client model applies none of them.
rowCountControlled statenumberundefinedTotal row count in manual mode, for the page count, the status line and aria-rowcount. Without it the status line leaves the total out.
loadingStatesbooleanfalseAnnounces the loading status and marks the table aria-busy; without rows it draws skeletonRows placeholder rows.
fetchingStatesbooleanfalseBackground refresh: status, aria-busy and data-fetching on the root (the body dims) while rows stay visible.
errorStatesstringundefinedText of an alert rendered above the table.
onRetryStates() => voidundefinedAdds the retry button to the alert.
emptyStatesSnippet<[EmptyContext]>undefinedContent of the single body cell when the row model has no rows. Receives search, filtered and loading to tell no matches from no data.
skeletonRowsStatesnumber5Placeholder rows drawn while loading without rows; 0 shows the loading message instead.
pageCountControlled statenumberfrom rowCountManual mode: the page count when the total is unknown, for example pageIndex + 2 while the server reports a next page.
searchDelayControlsnumber0Milliseconds between the last keystroke in the search box or a filter input and the state change; clearing a field applies at once.
searchableControlsbooleanfalseShows the search input. state.search applies without it, so a search box elsewhere in the page can write the state.
EmptyContextRow model{ search: string; filtered: boolean; loading: boolean }–Argument of the empty snippet; filtered is true while a column filter holds a value.
loadingTableMessagesstring'Loading rows…'Status text while loading or fetching.
emptyTableMessagesstring'No matching rows'Body text when no row matches.
retryTableMessagesstring'Retry'Retry button in the error alert.
@svelte-lean/table/serverEntry pointscreateDataSource–Sequencing and abort of a loader the application supplies.