List
List controllers combine query composables with pagination, filters, sorters, and optional route sync.
That route sync keeps list state in the URL query, so one page can treat the whole workflow as one controller instead of many separate refs.
This page covers useList and useInfiniteList.
List
useList combines resource resolution, useGetList, pagination state, filters state, sorters state, and optional route sync.
Use it for classic index pages where the UI needs page controls, filter controls, sort controls, and shareable URL state.
It returns useGetList result and some extras.
| Extra | Meaning |
|---|---|
records | The array that the page usually renders. In client mode, this is the sliced page data. |
currentPage | Current number page ref. |
perPage | Current page size ref. |
filters | Current filters state. |
setFilters | Update filters with merge or replace. |
sorters | Current sorters state. |
setSorters | Update sorters. |
total | Total record count from query data. |
pageCount | Computed page count from total and perPage. |
data is still the full fetcher result.
records is the array that the page usually renders.
total is useful for pagination UI. pageCount is useful for previous and next controls.
Query state still comes from useGetList, so the controller also exposes fields such as isLoading, error, and refetch.
When perPage, filters, or sorters change, currentPage resets to the initial page.
That reset is deliberate: once the query shape changes, the old page number is often no longer meaningful.
<script setup lang="ts">
import { FilterOperator } from '@ginjou/core'
import { useList } from '@ginjou/vue'
import { ref, watch } from 'vue'
const search = ref('')
const listPage = useList({
resource: 'posts',
pagination: {
current: 1,
perPage: 10,
},
sorters: [
{ field: 'createdAt', order: 'desc' },
],
})
watch(search, (value) => {
if (!value) {
listPage.setFilters([], 'replace')
return
}
listPage.setFilters([
{ field: 'title', operator: FilterOperator.contains, value },
], 'replace')
})
function sortNewest() {
listPage.setSorters([
{ field: 'createdAt', order: 'desc' },
])
}
function nextPage() {
listPage.currentPage.value += 1
}
function prevPage() {
listPage.currentPage.value = Math.max(listPage.currentPage.value - 1, 1)
}
</script>
<template>
<div>
<input v-model="search" placeholder="Search posts">
<button @click="sortNewest">
Newest
</button>
<div v-if="listPage.isLoading">
Loading...
</div>
<div v-else-if="listPage.error">
Something went wrong.
</div>
<div v-else-if="!listPage.records?.length">
No records.
</div>
<ul v-else>
<li v-for="post in listPage.records" :key="post.id">
{{ post.title }}
</li>
</ul>
<button @click="prevPage">
Prev
</button>
<button @click="nextPage">
Next
</button>
</div>
</template>
<script lang="ts">
import { FilterOperator } from '@ginjou/core'
import { useList } from '@ginjou/svelte'
let search = $state('')
const listPage = useList({
resource: 'posts',
pagination: {
current: 1,
perPage: 10,
},
sorters: [
{ field: 'createdAt', order: 'desc' },
],
})
$effect(() => {
if (!search) {
listPage.setFilters([], 'replace')
return
}
listPage.setFilters([
{ field: 'title', operator: FilterOperator.contains, value: search },
], 'replace')
})
function sortNewest() {
listPage.setSorters([
{ field: 'createdAt', order: 'desc' },
])
}
function nextPage() {
listPage.currentPage += 1
}
function prevPage() {
listPage.currentPage = Math.max(listPage.currentPage - 1, 1)
}
</script>
<div>
<input bind:value={search} placeholder="Search posts">
<button onclick={sortNewest}>Newest</button>
{#if listPage.isLoading}
Loading...
{:else if listPage.error}
Something went wrong.
{:else if !listPage.records?.length}
No records.
{:else}
<ul>
{#each listPage.records as post (post.id)}
<li>{post.title}</li>
{/each}
</ul>
{/if}
<button onclick={prevPage}>Prev</button>
<button onclick={nextPage}>Next</button>
</div>
Infinite List
useInfiniteList shares most of the same state model, but the query layer changes to useGetInfiniteList.
Use it when the page should load forward or backward through pages instead of jumping between numbered pages.
It returns useGetInfiniteList result, which inherits TanStack Query useInfiniteQuery state and methods.
Then the controller adds some extras.
| Extra | Meaning |
|---|---|
records | Page arrays from loaded data. The shape is TResultData[][]. |
currentPage | Current page param ref. |
perPage | Current page size ref. |
filters | Current filters state. |
setFilters | Update filters. |
sorters | Current sorters state. |
setSorters | Update sorters. |
total | Total count from the last loaded page. |
pageCount | Computed page count from total and perPage. |
data is still the full infinite query result, so it contains pages and pageParams.
Compared with useList, currentPage can be a number page or a cursor value.
Like useList, changing perPage, filters, or sorters resets currentPage to the initial page.
<script setup lang="ts">
import { useInfiniteList } from '@ginjou/vue'
const feed = useInfiniteList({
resource: 'posts',
pagination: {
init: 1,
perPage: 10,
},
})
</script>
<template>
<div v-for="(page, pageIndex) in feed.records" :key="pageIndex">
<ul>
<li v-for="post in page" :key="post.id">
{{ post.title }}
</li>
</ul>
</div>
<button
:disabled="!feed.hasNextPage || feed.isFetchingNextPage"
@click="feed.fetchNextPage()"
>
{{ feed.isFetchingNextPage ? 'Loading...' : 'Load more' }}
</button>
</template>
<script lang="ts">
import { useInfiniteList } from '@ginjou/svelte'
const feed = useInfiniteList({
resource: 'posts',
pagination: {
init: 1,
perPage: 10,
},
})
</script>
{#each feed.records ?? [] as page, pageIndex (pageIndex)}
<ul>
{#each page as post (post.id)}
<li>{post.title}</li>
{/each}
</ul>
{/each}
<button
disabled={!feed.hasNextPage || feed.isFetchingNextPage}
onclick={() => feed.fetchNextPage()}
>
{feed.isFetchingNextPage ? 'Loading...' : 'Load more'}
</button>
Pagination
Pagination state has four main fields.
| Field | Meaning |
|---|---|
mode | Pagination strategy for useList: server, client, or off. |
current | Current page value. |
perPage | Page size. |
init | Initial page value. |
server is the default.
For most CRUD lists, that is the right starting point.
Modes
| Mode | What happens |
|---|---|
server | Send current and perPage to the fetcher. |
client | Fetch without server pagination, then slice records on the client. |
off | Do not paginate at all. |
Pagination mode is mainly for useList.
useInfiniteList does not have pagination mode. It always works with a page param and fetchNextPage or fetchPreviousPage.Use off when the page should show one full result set without page controls.
Use client when the backend returns one larger set and the page wants to paginate locally.
<script setup lang="ts">
import { useList } from '@ginjou/vue'
const listPage = useList({
resource: 'posts',
pagination: {
mode: 'client',
current: 1,
perPage: 20,
},
})
</script>
<script lang="ts">
import { useList } from '@ginjou/svelte'
const listPage = useList({
resource: 'posts',
pagination: {
mode: 'client',
current: 1,
perPage: 20,
},
})
</script>
Number Page
Number pages are the normal list case.
currentPage, perPage, and pageCount are enough for previous, next, and page size UI.
<script setup lang="ts">
import { useList } from '@ginjou/vue'
import { ref, watch } from 'vue'
const pageSize = ref(10)
const listPage = useList({
resource: 'posts',
pagination: {
current: 1,
perPage: 10,
},
})
watch(pageSize, (value) => {
listPage.perPage.value = value
})
function nextPage() {
if (listPage.pageCount.value != null && listPage.currentPage.value >= listPage.pageCount.value)
return
listPage.currentPage.value += 1
}
function prevPage() {
listPage.currentPage.value = Math.max(listPage.currentPage.value - 1, 1)
}
</script>
<template>
<select v-model.number="pageSize">
<option :value="10">
10
</option>
<option :value="20">
20
</option>
<option :value="50">
50
</option>
</select>
<button @click="prevPage">
Prev
</button>
<span>{{ listPage.currentPage }} / {{ listPage.pageCount }}</span>
<button @click="nextPage">
Next
</button>
</template>
<script lang="ts">
import { useList } from '@ginjou/svelte'
let pageSize = $state(10)
const listPage = useList({
resource: 'posts',
pagination: {
current: 1,
perPage: 10,
},
})
$effect(() => {
listPage.perPage = pageSize
})
function nextPage() {
if (listPage.pageCount != null && listPage.currentPage >= listPage.pageCount)
return
listPage.currentPage += 1
}
function prevPage() {
listPage.currentPage = Math.max(listPage.currentPage - 1, 1)
}
</script>
<select bind:value={pageSize}>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
</select>
<button onclick={prevPage}>Prev</button>
<span>{listPage.currentPage} / {listPage.pageCount}</span>
<button onclick={nextPage}>Next</button>
Cursor Page
Cursor pagination is the common infinite list case.
If the fetcher returns cursor.next or cursor.prev, fetchNextPage and fetchPreviousPage use those values as the next page param.
This is different from number pages. The page param can be a token or another cursor type, not only a number.
<script setup lang="ts">
import { useInfiniteList } from '@ginjou/vue'
const feed = useInfiniteList<any, unknown, any, string>({
resource: 'messages',
pagination: {
init: 'start',
perPage: 20,
},
})
</script>
<template>
<button
:disabled="!feed.hasPreviousPage || feed.isFetchingPreviousPage"
@click="feed.fetchPreviousPage()"
>
Load previous
</button>
<button
:disabled="!feed.hasNextPage || feed.isFetchingNextPage"
@click="feed.fetchNextPage()"
>
Load next
</button>
</template>
<script lang="ts">
import { useInfiniteList } from '@ginjou/svelte'
const feed = useInfiniteList<any, unknown, any, string>({
resource: 'messages',
pagination: {
init: 'start',
perPage: 20,
},
})
</script>
<button
disabled={!feed.hasPreviousPage || feed.isFetchingPreviousPage}
onclick={() => feed.fetchPreviousPage()}
>
Load previous
</button>
<button
disabled={!feed.hasNextPage || feed.isFetchingNextPage}
onclick={() => feed.fetchNextPage()}
>
Load next
</button>
Filters
Filters are both query conditions and list state.
That is why useList exposes both the current filter value and setFilters().
| Field | Meaning |
|---|---|
value | Initial user-controlled filters. |
permanent | Filters that always stay in the query. |
behavior | Default behavior for setFilters. |
mode | server or off. |
Filters use the same filter shape as useGetList, including nested and and or groups.
Behavior
| Behavior | What it does |
|---|---|
merge | Keep previous user filters when fields do not conflict. |
replace | Rebuild user-controlled filters, while permanent filters still stay. |
<script setup lang="ts">
import { FilterOperator } from '@ginjou/core'
import { useList } from '@ginjou/vue'
const listPage = useList({
resource: 'posts',
filters: {
behavior: 'merge',
},
})
function mergeSearch(value: string) {
listPage.setFilters([
{ field: 'title', operator: FilterOperator.contains, value },
], 'merge')
}
function replaceAdvanced() {
listPage.setFilters([
{ field: 'status', operator: FilterOperator.eq, value: 'published' },
], 'replace')
}
</script>
<script lang="ts">
import { FilterOperator } from '@ginjou/core'
import { useList } from '@ginjou/svelte'
const listPage = useList({
resource: 'posts',
filters: {
behavior: 'merge',
},
})
function mergeSearch(value: string) {
listPage.setFilters([
{ field: 'title', operator: FilterOperator.contains, value },
], 'merge')
}
function replaceAdvanced() {
listPage.setFilters([
{ field: 'status', operator: FilterOperator.eq, value: 'published' },
], 'replace')
}
</script>
Permanent
Permanent filters are useful for tenant scope, fixed status, or page business rules.
They are always merged into the final filters state, even when user-controlled filters change.
<script setup lang="ts">
import { FilterOperator } from '@ginjou/core'
import { useList } from '@ginjou/vue'
const listPage = useList({
resource: 'posts',
filters: {
permanent: [
{ field: 'tenantId', operator: FilterOperator.eq, value: 'team-a' },
],
},
})
</script>
<script lang="ts">
import { FilterOperator } from '@ginjou/core'
import { useList } from '@ginjou/svelte'
const listPage = useList({
resource: 'posts',
filters: {
permanent: [
{ field: 'tenantId', operator: FilterOperator.eq, value: 'team-a' },
],
},
})
</script>
Mode
| Mode | What it does |
|---|---|
server | Send filters to the query. |
off | Keep filters in controller state, but do not send them to the query. |
<script setup lang="ts">
import { useList } from '@ginjou/vue'
const listPage = useList({
resource: 'posts',
filters: {
mode: 'off',
},
})
</script>
<script lang="ts">
import { useList } from '@ginjou/svelte'
const listPage = useList({
resource: 'posts',
filters: {
mode: 'off',
},
})
</script>
Sorters
Sorters are also part of the list state.
Like filters, they live in controller state first and only reach the backend when sorter mode allows it.
| Field | Meaning |
|---|---|
value | Initial user-controlled sorters. |
permanent | Sorters that always stay in the query. |
mode | server or off. |
There is no sorter behavior option.
Updating sorters always replaces the user-controlled sorter set while preserving permanent sorters.
Permanent
When you update sorters, the controller replaces user-controlled sorters and keeps permanent sorters.
Permanent sorters are useful for stable secondary order.
<script setup lang="ts">
import { useList } from '@ginjou/vue'
const listPage = useList({
resource: 'posts',
sorters: {
permanent: [
{ field: 'id', order: 'desc' },
],
},
})
function sortByCreatedAt() {
listPage.setSorters([
{ field: 'createdAt', order: 'desc' },
])
}
</script>
<script lang="ts">
import { useList } from '@ginjou/svelte'
const listPage = useList({
resource: 'posts',
sorters: {
permanent: [
{ field: 'id', order: 'desc' },
],
},
})
function sortByCreatedAt() {
listPage.setSorters([
{ field: 'createdAt', order: 'desc' },
])
}
</script>
Mode
| Mode | What it does |
|---|---|
server | Send sorters to the query. |
off | Keep sorters in controller state, but do not send them to the query. |
<script setup lang="ts">
import { useList } from '@ginjou/vue'
const listPage = useList({
resource: 'posts',
sorters: {
mode: 'off',
},
})
function sortByAuthorThenTitle() {
listPage.setSorters([
{ field: 'author', order: 'asc' },
{ field: 'title', order: 'asc' },
])
}
</script>
<script lang="ts">
import { useList } from '@ginjou/svelte'
const listPage = useList({
resource: 'posts',
sorters: {
mode: 'off',
},
})
function sortByAuthorThenTitle() {
listPage.setSorters([
{ field: 'author', order: 'asc' },
{ field: 'title', order: 'asc' },
])
}
</script>
Sync With Route
Route sync keeps pagination, filters, and sorters in the URL query.
It reads from useLocation and writes updates with go.
The controller watches list state and writes the URL with a 100ms debounced replace navigation.
That makes filters, sorters, and pagination shareable without forcing every page to hand-roll URL sync.
With syncRoute: true, useList uses these default query fields.
| State | Default field |
|---|---|
| current page | current |
| per page | perPage |
| filters | filters |
| sorters | sorters |
useInfiniteList syncs perPage, filters, and sorters, but not the current page param.
Without router context, the controller still works as local state. Route sync simply stays inactive.
<script setup lang="ts">
import { useList } from '@ginjou/vue'
const listPage = useList({
resource: 'posts',
syncRoute: true,
})
</script>
<script lang="ts">
import { useList } from '@ginjou/svelte'
const listPage = useList({
resource: 'posts',
syncRoute: true,
})
</script>
Custom Fields
You can enable, disable, or rename each field.
This is useful when only part of the list state should be shareable in the URL.
Enable a field with its default name:
<script setup lang="ts">
import { useList } from '@ginjou/vue'
const listPage = useList({
resource: 'posts',
syncRoute: {
currentPage: true,
perPage: true,
filters: true,
sorters: true,
},
})
</script>
<script lang="ts">
import { useList } from '@ginjou/svelte'
const listPage = useList({
resource: 'posts',
syncRoute: {
currentPage: true,
perPage: true,
filters: true,
sorters: true,
},
})
</script>
Disable a field:
<script setup lang="ts">
import { useList } from '@ginjou/vue'
const listPage = useList({
resource: 'posts',
syncRoute: {
currentPage: true,
perPage: true,
filters: false,
sorters: false,
},
})
</script>
<script lang="ts">
import { useList } from '@ginjou/svelte'
const listPage = useList({
resource: 'posts',
syncRoute: {
currentPage: true,
perPage: true,
filters: false,
sorters: false,
},
})
</script>
Rename a field:
<script setup lang="ts">
import { useList } from '@ginjou/vue'
const listPage = useList({
resource: 'posts',
syncRoute: {
currentPage: { field: 'page' },
perPage: { field: 'size' },
filters: false,
sorters: false,
},
})
</script>
<script lang="ts">
import { useList } from '@ginjou/svelte'
const listPage = useList({
resource: 'posts',
syncRoute: {
currentPage: { field: 'page' },
perPage: { field: 'size' },
filters: false,
sorters: false,
},
})
</script>
Parse / Stringify
filters and sorters can define custom parse and stringify functions.
Use this when the default JSON format is too long or does not match an existing URL contract.
<script setup lang="ts">
import { useList } from '@ginjou/vue'
const listPage = useList({
resource: 'posts',
syncRoute: {
filters: {
field: 'f',
stringify: value => encodeURIComponent(JSON.stringify(value)),
parse: value => JSON.parse(decodeURIComponent(value)),
},
sorters: {
field: 's',
stringify: value => value.map(item => `${item.field}:${item.order}`).join(','),
parse: value => value.split(',').filter(Boolean).map((item) => {
const [field, order] = item.split(':')
return { field, order }
}),
},
},
})
</script>
<script lang="ts">
import { useList } from '@ginjou/svelte'
const listPage = useList({
resource: 'posts',
syncRoute: {
filters: {
field: 'f',
stringify: value => encodeURIComponent(JSON.stringify(value)),
parse: value => JSON.parse(decodeURIComponent(value)),
},
sorters: {
field: 's',
stringify: value => value.map(item => `${item.field}:${item.order}`).join(','),
parse: value => value.split(',').filter(Boolean).map((item) => {
const [field, order] = item.split(':')
return { field, order }
}),
},
},
})
</script>