# Server data

> Manual mode, rowCount, loading, error and empty states, retry, and stale-request handling left to the application.

Page: https://svelte-lean.vercel.app/table/server-data · Markdown: https://svelte-lean.vercel.app/table/server-data.md · Index: https://svelte-lean.vercel.app/llms.txt

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.

- **Source**: server.tscore.ts (paginateNodes, manual)
- **Tests**: core.test.tsdata-table.dom.test.tstable.test.tstable.spec.ts (browser)
- **Fixture**: fixtures/table-basic
- **Benchmark**: table.bench.spec.ts

`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`

```ts
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.

ServerData.svelte

```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="outlined" data-color="neutral" 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.

## Unknown total and debounced search

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

UnknownTotal.svelte

```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"`](https://svelte-lean.vercel.app/table/layout.md#grid-layout).

- 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.

LoadingEmpty.svelte

```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="outlined" data-color="neutral" onclick={() => (rows = [])}>Empty</button>
<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" onclick={() => (error = 'The example endpoint returned 503.')}>Error</button>
<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" 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 (hidden from assistive technology). By default a paginated table draws a page of them, at most 25, so the pager and what follows the table do not move when the rows arrive; an unpaginated one draws 5; 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`.
- `refreshing` is the quiet one (a poll, an invalidation): it sets `data-refreshing`, which draws a thin line along the top edge (`--slean-table-refreshing-color`, `--slean-table-refreshing-height`; it pulses only without reduced motion). Rows stay as they are, nothing is announced, there is no `aria-busy`, and the page clamp below stays active. `loading` and `fetching` win over it.
- `--slean-table-skeleton-delay` keeps skeleton rows invisible for that long (a CSS animation delay, no timer), so an answer faster than the delay shows no skeleton flash. The default is `0s`.
- A page that no longer exists (the rows of the last page were removed, or `pageCount` shrank) is left for the last one there is: once the table is neither loading nor fetching, `state.pageIndex` follows and `onStateChange` fires, so a manual table asks for that page instead of drawing an empty one.

## Empty states

Tell "nothing matches" from "nothing here" and offer the way back.

EmptyStates.svelte

```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="outlined"
						data-color="neutral"
						data-size="small"
						onclick={() => (tableState = { ...tableState, search: '', pageIndex: 0 })}
						>Clear search</button
					>{/if}
				{#if filtered}<button
						type="button"
						data-slean="button"
						data-variant="outlined"
						data-color="neutral"
						data-size="small"
						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="small"
					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="outlined" data-color="neutral" 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

| Name | Of | Type | Default | Description |
| --- | --- | --- | --- | --- |
| [`manual`](https://svelte-lean.vercel.app/table/api.md#prop-manual) | Controlled state | `boolean` | `false` | Treats data as already sorted, filtered and paged by the application; the client model applies none of them. |
| [`rowCount`](https://svelte-lean.vercel.app/table/api.md#prop-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`](https://svelte-lean.vercel.app/table/api.md#prop-loading) | States | `boolean` | `false` | Announces the loading status and marks the table aria-busy; without rows it draws skeletonRows placeholder rows. |
| [`fetching`](https://svelte-lean.vercel.app/table/api.md#prop-fetching) | States | `boolean` | `false` | Background refresh: status, aria-busy and data-fetching on the root (the body dims) while rows stay visible. |
| [`refreshing`](https://svelte-lean.vercel.app/table/api.md#prop-refreshing) | States | `boolean` | `false` | Quiet refresh (a poll, an invalidation): data-refreshing on the root draws a thin line along the top edge. Rows are not dimmed or replaced, no aria-busy, no status, page clamping stays on. Ignored while loading or fetching. |
| [`error`](https://svelte-lean.vercel.app/table/api.md#prop-error) | States | `string` | `undefined` | Text of an alert rendered above the table. |
| [`onRetry`](https://svelte-lean.vercel.app/table/api.md#prop-onretry) | States | `() => void` | `undefined` | Adds the retry button to the alert. |
| [`empty`](https://svelte-lean.vercel.app/table/api.md#prop-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`](https://svelte-lean.vercel.app/table/api.md#prop-skeletonrows) | States | `number` | `page size (at most 25) when paginated, else 5` | Placeholder rows drawn while loading without rows; 0 shows the loading message instead. |
| [`pageCount`](https://svelte-lean.vercel.app/table/api.md#prop-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`](https://svelte-lean.vercel.app/table/api.md#prop-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`](https://svelte-lean.vercel.app/table/api.md#prop-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`](https://svelte-lean.vercel.app/table/api.md#type-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`](https://svelte-lean.vercel.app/table/api.md#message-loading) | TableMessages | `string` | `'Loading rows…'` | Status text while loading or fetching. |
| [`empty`](https://svelte-lean.vercel.app/table/api.md#message-empty) | TableMessages | `string` | `'No matching rows'` | Body text when no row matches. |
| [`retry`](https://svelte-lean.vercel.app/table/api.md#message-retry) | TableMessages | `string` | `'Retry'` | Retry button in the error alert. |
| [`@svelte-lean/table/server`](https://svelte-lean.vercel.app/table/api.md#module--svelte-lean-table-server) | Entry points | `createDataSource` | – | Sequencing and abort of a loader the application supplies. |

## Related

- [Pagination](https://svelte-lean.vercel.app/table/pagination.md)
- [State persistence](https://svelte-lean.vercel.app/table/state-persistence.md)
- [Filtering](https://svelte-lean.vercel.app/table/filtering.md)
