Guides

List

Explain list and infinite list controllers, their state model, and route sync.

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.

ExtraMeaning
recordsThe array that the page usually renders. In client mode, this is the sliced page data.
currentPageCurrent number page ref.
perPageCurrent page size ref.
filtersCurrent filters state.
setFiltersUpdate filters with merge or replace.
sortersCurrent sorters state.
setSortersUpdate sorters.
totalTotal record count from query data.
pageCountComputed 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>

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.

ExtraMeaning
recordsPage arrays from loaded data. The shape is TResultData[][].
currentPageCurrent page param ref.
perPageCurrent page size ref.
filtersCurrent filters state.
setFiltersUpdate filters.
sortersCurrent sorters state.
setSortersUpdate sorters.
totalTotal count from the last loaded page.
pageCountComputed 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>

Pagination

Pagination state has four main fields.

FieldMeaning
modePagination strategy for useList: server, client, or off.
currentCurrent page value.
perPagePage size.
initInitial page value.

server is the default.

For most CRUD lists, that is the right starting point.

Modes

ModeWhat happens
serverSend current and perPage to the fetcher.
clientFetch without server pagination, then slice records on the client.
offDo 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>

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>

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>

Filters

Filters are both query conditions and list state.

That is why useList exposes both the current filter value and setFilters().

FieldMeaning
valueInitial user-controlled filters.
permanentFilters that always stay in the query.
behaviorDefault behavior for setFilters.
modeserver or off.

Filters use the same filter shape as useGetList, including nested and and or groups.

Behavior

BehaviorWhat it does
mergeKeep previous user filters when fields do not conflict.
replaceRebuild 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>

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>

Mode

ModeWhat it does
serverSend filters to the query.
offKeep 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>

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.

FieldMeaning
valueInitial user-controlled sorters.
permanentSorters that always stay in the query.
modeserver 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>

Mode

ModeWhat it does
serverSend sorters to the query.
offKeep 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>

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.

StateDefault field
current pagecurrent
per pageperPage
filtersfilters
sorterssorters

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>

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>

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>

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>

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>
Copyright © 2026