# Lists

> Server-paged lists with createPaged and DataTable, and "load more" lists with createInfinite.

Page: https://svelte-lean.vercel.app/query/lists · Markdown: https://svelte-lean.vercel.app/query/lists.md · Index: https://svelte-lean.vercel.app/llms.txt

A server-paged list is one query per page with page state around it; a “load more” list is a walk over the same page entries.

> **Note** `./infinite` is experimental: the API can still change in a minor release. Stability per entry point: [Entry points](https://svelte-lean.vercel.app/query.md#entry-points).

## Paged table

`createPaged` takes the endpoint and the filters; `list.table` is the set of props a `<DataTable manual>` needs.

`src/routes/members/+page.svelte`

```svelte
<!-- src/routes/members/+page.svelte -->
<script lang="ts">
	import { createPaged, tablePaging } from '@svelte-lean/query/paged';
	import { DataTable, defaultState, type Column } from '@svelte-lean/table';
	import '@svelte-lean/table/style.css';
	import { listMembers } from '$api/members';
	import type { Member } from '$api/types';

	const columns: Column<Member>[] = [
		{ id: 'name', header: 'Name', accessor: 'name' },
		{ id: 'email', header: 'Email', accessor: 'email' },
		{ id: 'team', header: 'Team', accessor: 'team' }
	];

	let view = $state(defaultState({ pageSize: 25 }));
	let team = $state('');

	// The page lives in the table state; a filter change resets it to 1.
	const list = createPaged(listMembers, () => ({ team: team || undefined }), {
		...tablePaging(() => view),
		poll: () => '30s'
	});
</script>

<label>
	Team
	<select bind:value={team}>
		<option value="">All</option>
		<option>Engineering</option>
		<option>Design</option>
	</select>
</label>

<DataTable
	bind:state={view}
	{...list.table}
	{columns}
	getRowId={(member) => member.id}
	label="Members"
/>
```

- A filter change resets the page to 1 in the same update; no request for the old page.
- A page past the end is clamped to the last page.
- Rows stay on screen while the next page loads (`loading` dims them); a poll sets `refreshing`, a thin line, and writes no row that did not change.
- Leaving a page before its answer aborts that request.

### Table props

`list.table` is one object created with the list; each prop reads its own signal, so a poll that flips `refreshing` re-renders neither the rows nor the pager.

| Prop | Value |
| --- | --- |
| `manual` | `true`: the server pages, sorts and filters |
| `data` | the rows of the current page |
| `rowCount` | the total, or `undefined` when the server sent a bare list |
| `pageCount` | the last page |
| `loading`, `fetching` | a fetch the user waits for |
| `refreshing` | a quiet background fetch |

### Page state in the table

`state`

```svelte
let view = $state(defaultState({ pageSize: 25 }));
let team = $state('');

// The page lives in the table state; a filter change resets it to 1.
const list = createPaged(listMembers, () => ({ team: team || undefined }), {
	...tablePaging(() => view),
	poll: () => '30s'
});
```

`tablePaging(() => view)` keeps the page and the page size in the table state (0-based there, 1-based here); `bind:state` replacing the object is supported.

## List with a pager

`members.svelte`

```svelte
<!-- A paged list without DataTable: page state and a native pager -->
<script lang="ts">
	import { createPaged } from '@svelte-lean/query/paged';
	import { listMembers } from '$api/members';

	let search = $state('');
	const list = createPaged(listMembers, () => ({ search }), {
		perPage: 20,
		prefetch: 'next' // the next page is requested once this one has settled
	});
</script>

<input type="search" aria-label="Search members" bind:value={search} />
<ul aria-busy={list.loading}>
	{#each list.rows as member (member.id)}<li>{member.name}</li>{/each}
</ul>
<nav aria-label="Pages">
	<button disabled={list.page <= 1} onclick={() => list.setPage(list.page - 1)}>Previous</button>
	<span>Page {list.page} of {list.lastPage}</span>
	<button disabled={list.page >= list.lastPage} onclick={() => list.setPage(list.page + 1)}
		>Next</button
	>
</nav>
```

### Options

| Option | Type | Default | Effect |
| --- | --- | --- | --- |
| `page` | `number \| Controlled<number>` | `1` | The page, 1-based; a Controlled value lives elsewhere (table state, URL). |
| `perPage` | `number \| Controlled<number>` | `20` | Rows per page; changing it resets the page to 1. |
| `keepPrevious` | `boolean` | `true` | The previous page stays on screen while the next one loads. |
| `poll` | `PollOption \| (() => PollOption)` | `–` | Polls the current page only. |
| `when` | `boolean \| (() => boolean)` | `–` | Pauses fetching and polling; keeps the rows. |
| `prefetch` | `'next' \| 'neighbours' \| false` | `false` | Requests the adjacent pages after an answer has settled. |
| `fresh` | `Duration` | `the endpoint’s` | Per-use freshness. |

Page params come from the transport profile (`{ page, perPage }` without one, `page` and `per_page` with `laravel()`); keep them out of the filters. An endpoint that names or sends them differently declares `pageParams: (page, perPage) => object`: `(page) => ({ page })` when the server sizes its pages, `() => ({})` for a list it does not page.

## Load more

`src/routes/audit/+page.svelte`

```svelte
<!-- src/routes/audit/+page.svelte -->
<script lang="ts">
	import { createInfinite } from '@svelte-lean/query/infinite';
	import { listAuditEvents } from '$api/audit';

	let actor = $state('');
	const feed = createInfinite(listAuditEvents, () => ({ actor: actor || undefined }), {
		perPage: 50,
		poll: () => '15s' // the head page only
	});
</script>

<input type="search" aria-label="Filter by actor" bind:value={actor} />
{#if feed.loading}<p>Loading events…</p>{/if}
<ol>
	{#each feed.rows as event (event.id)}
		<li><time datetime={event.at}>{event.at}</time> {event.actor} {event.action}</li>
	{/each}
</ol>
{#if feed.moreError}<p role="alert">The next page could not be loaded.</p>{/if}
{#if feed.hasMore}
	<button onclick={() => feed.loadMore()} disabled={feed.loadingMore}>
		{feed.loadingMore ? 'Loading…' : 'Load more'}
	</button>
{/if}
```

- Each page is an ordinary entry: it fails, refetches and is evicted on its own.
- Only the head page polls; a failed deeper page sets `moreError` and keeps the rows above it.
- A row id seen on an earlier page is kept at its first position.
- `refetch()` refetches every walked page in parallel.
- `loading`, `refreshing`, `busy`, `blocked` and `info` read like those of a query (of the head page).

### Options

| Option | Type | Default | Effect |
| --- | --- | --- | --- |
| `perPage` | `number` | `20` | Rows per page. |
| `poll` | `PollOption \| (() => PollOption)` | `–` | Polls the head page only. |
| `shift` | `'refetch' \| 'carry'` | `'refetch'` | What a head refresh with new rows does to the deeper pages. |
| `maxPages` | `number` | `none` | A hard cap on the walk. |
| `keepPrevious` | `boolean` | `false` | Keep the rows of the previous params while the new walk starts. |
| `when` | `boolean \| (() => boolean)` | `–` | Pauses the walk and polling; keeps the rows. |

The endpoint answers `page<T>()`, like a paged one:

`src/lib/api/audit.ts`

```ts
// src/lib/api/audit.ts — the workspace audit log, read page by page.
import { defineQuery, page, type } from '@svelte-lean/query';
import type { AuditEvent } from './types';

declare module '@svelte-lean/query' {
	interface Tags {
		AuditEvents: void;
	}
}

export const listAuditEvents = defineQuery({
	get: 'api/audit',
	params: type<{ actor?: string }>(),
	result: page<AuditEvent>(),
	tags: ['AuditEvents']
});
```

## Reference

- [@svelte-lean/query/paged](https://svelte-lean.vercel.app/query/api/paged.md)
- [@svelte-lean/query/infinite](https://svelte-lean.vercel.app/query/api/infinite.md)
- [Table: server data](https://svelte-lean.vercel.app/table/server-data.md)
