Pagination, Search & Offline List Cache
SkillSearchEsposter paginated-list conventions — the three-layer cursor pagination pattern (store + useRead* composable + StyledWaypoint), a keyed write naming its key when issued (the ambient items being readonly), infinite scroll instead of a Load-more button, the ban on hand-rolling search-as-you-type and MiniSearch being the one client-side index, bundling ancillary reads into the primary read, a total over the list being the server's rather than a count of the loaded rows (and moving under every optimistic write's rollback), a keyed read's query closure never running on the hydrating client so only Pinia state survives it, re-reading a list after a push (compare against the server half, pair the timestamp watermark with the ids already held because equal timestamps tie, and queue overlapping re-reads), and the offline IndexedDB cache being self-contained, plus deep dives on wiring useAutoSearch/useCursorSearcher with its sanctioned exceptions and on the feature cache composables. Apply when building or reviewing a paginated list, an infinite-scroll feed, a search-as-you-type input, or an offline list cache.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Pagination, Search & Offline List Cache skill
What this skill tells your AI
The instructions your AI receives, as published by esposter/esposter in .agents/skills/pagination/SKILL.md and read by ahel’s review.
Cursor Pagination — Store + Composable + Waypoint
Every paginated list follows a three-layer pattern. Never load pages directly in a component or store a raw array for paginated data.
Layer 1 — Store
Call useCursorPaginationData<TItem>() (handles the ref + cast internally). Expose hasMore, items, readItems, readMoreItems:
export const useFooStore = defineStore("feature/foo", () => {
const { hasMore, items, readItems, readMoreItems } = useCursorPaginationData<FooEntity>();
// mutations update items.value directly (optimistic or after server response)
return { hasMore, items, readItems, readMoreItems };
});
Layer 2 — useRead* Composable
Wrap readItems (first page) and readMoreItems (subsequent pages) with tRPC calls. The readMoreItems callback receives the current cursor automatically. Omit roomId for global (non-room-scoped) lists.
export const useReadFoos = (roomId: string) => {
const { $trpc } = useNuxtApp();
const fooStore = useFooStore();
const { readItems, readMoreItems } = fooStore;
const readFoos = () => readItems(() => $trpc.foo.readFoos.query({ roomId }));
const readMoreFoos = (onComplete: () => void) =>
readMoreItems((cursor) => $trpc.foo.readFoos.query({ cursor, roomId }), onComplete);
return { readFoos, readMoreFoos };
};
Layer 3 — Component / Page
await readFoos() at setup time, destructure hasMore + items via storeToRefs, and place <StyledWaypoint> at the bottom of the list (inside the container, after all items). It only triggers when :is-active is true, so always rendering it is safe.
<script setup lang="ts">
const { readFoos, readMoreFoos } = useReadFoos(roomId);
const fooStore = useFooStore();
const { hasMore, items } = storeToRefs(fooStore);
await readFoos();
</script>
<template>
<v-list v-if="items.length > 0">
<v-list-item v-for="item of items" :key="item.id" ... />
<StyledWaypoint :is-active="hasMore" @change="readMoreFoos" />
</v-list>
</template>
Rules
- Never store a paginated list as a plain
ref<TItem[]>— alwaysCursorPaginationData<TItem>. - Never call
readItems/readMoreItemsfrom a component directly — always via auseRead*composable. - Optimistic mutations update
items.valuedirectly (spread for create, filter for delete) — no re-fetch. readMoreItemsappends;readItemsresets the fullCursorPaginationDataref (handles navigating back to first page).- A list on an SSR'd route passes
readItemsakeyfromAsyncDataKey; one behindssr: false(everything under/messages,/calls,/dungeons,/resource-explorer) passes none. Without a key the read runs twice per page load — the server issues it for the html, and hydration replays the same setup client-side — and the second answer can disagree with the rows already rendered. The key covers every input that changes which page the server rendered (the sort, the profile, the parent post), and only the hydrating render adopts the payload: a sort change, a pull to refresh and a client-side navigation all read live. - Which pagination helper a store uses (single list vs per-key lists) is the
piniaskill's (references/keyed-state-and-pagination.md). - The endpoint-side input schemas are the
trpcskill's (references/read-endpoints.md).
A keyed write names its key when the operation is issued, not when it lands
useCursorPaginationDataMap's ambient items is the reading view — it follows whichever key is current, which is what a rendered list wants and exactly what a write must not use, because a response landing after the reader moved on would file one key's rows under another's: one member's private moderation notes rendered against another member, one room's messages appended to another's.
So it is typed readonly, and the write does not compile. A writer comes from getSlice(key) alone, and obtaining one means naming the key — readItems/readMoreItems bind the current key up front and give this for free, so a useRead* composable needs nothing. Everything else resolves its slice where the operation is issued.
Two corollaries that are easy to get backwards:
- Resolve per operation, never per composable. A composable that outlives one target (
useMessageCacheis constructed once and lives across every room switch) would bind to the first key and stay there forever, which is worse than not binding at all — so a long-lived consumer takesgetSliceitself and resolves inside the operation. - A partition that has already been named needs no re-check.
usePaginationCachehydratesgetSlice(partitionKey)unconditionally, so a write that lands after the partition has moved on is still filed under the partition it was read for, and re-opening that partition shows it. A staleness guard on top of that — bailing because the current partition is no longer the one being hydrated — drops rows that are correctly filed.
Why the readonly type rather than a convention everyone remembers: the invariants skill.
StyledWaypoint — Infinite Scroll
Use <StyledWaypoint> for cursor-paginated lists instead of a "Load more" button. Never use a manual "Load more" v-btn with isLoadingMore state — that belongs to StyledWaypoint.
:is-active="hasMore"—v-showand deactivated when there are no more pages@change="readMoreXxx"— handler must accept(onComplete: () => void)and callonComplete()when done (via theonCompletearg toreadMoreItems)- Its observer is deliberately never torn down.
v-showalready hides an exhausted waypoint, and anIntersectionObserveron adisplay: noneelement reports not-intersecting and simply stops firing — so gatinguseElementVisibilityonisActive(awatchEffectthat re-observes, av-ifin place of thev-show) buys no work back and adds a re-observation race on the way in. Leave the observer alive for the component's life; this is the general rule in thevue-composable-patternsskill. - Default slot replaces the built-in loader entirely. The fallback is a
v-progress-circularrendered only while loading; supplying slot content overrides it and the slot gets noisLoadingprop, so passed skeletons render wheneverisActive— not just during a fetch. Omit the slot unless you want that always-visible placeholder.
<StyledWaypoint :is-active="hasMore" @change="readMoreFoos">
<FooSkeletonItem v-for="i in DEFAULT_READ_LIMIT" :key="i" />
</StyledWaypoint>
Server Search-as-You-Type — hand-rolling BANNED
Hand-rolling search-as-you-type around a $trpc search query is banned: no per-component useThrottle/refDebounced + watch + AbortController + isSearching wiring, and no @input handler firing a query. That stack exists exactly once, in useAutoSearch — reach for it, or for useCursorSearcher when the results are cursor-paginated.
Searching data that is already loaded is the other branch, and it is not a free-for-all. There is no request to throttle or abort, so useAutoSearch would be pure ceremony — but the index is MiniSearch, the same one docs search uses, queried by a computed. Never hand-roll a token map, a sorted-prefix array or a bespoke scorer: a second client-side search mechanism is exactly the drift the one stack exists to stop, and the hand-rolled one loses on relevance, which is the part that matters. Set combineWith: "AND" (the default unions terms) and prefix: true, boost the field the user is naming, and pin an exact hit ahead of the ranked results. Full standard, both branches: apps/web/content/docs/architecture/search.md.
Bundle Ancillary Reads with the Primary Read
When a component needs ancillary data (permissions, metadata) alongside a primary list load, bundle the ancillary read inside the primary read composable — not in the component's onMounted. An ancillary read belongs inside the composable owning the load (useReadFoos), called in Promise.all alongside other metadata reads. If there is no natural companion read, call it directly in <script setup> — still no onMounted.
// bundle ancillary reads in the owning read composable — not a separate component onMounted fetch
const readBars = useReadBars();
const readBazes = useReadBazes();
const readFoos = () =>
readItems(async () => {
const data = await $trpc.foo.readFoos.query();
const fooIds = data.items.map(({ id }) => id);
if (fooIds.length > 0) await Promise.all([readBars(fooIds), readBazes(fooIds)]);
return data;
});
Follow the useReadBars shape for batch ancillary reads — a composable taking an array of ids, early-returning when it is empty, and issuing one batched query rather than N per-id calls.
A total over a paginated list is the server's, never the loaded rows
Any number a surface shows about the whole list — an unread badge, a "N items" summary, a filtered tally — is
computed by the endpoint and returned with the page, because the client holds one page and the answer is about
all of them. A computed counting items is right only for a list that is never paginated (a client-owned half
that exists solely in the tab), and it fails in the direction nobody notices: it reads low, and it reads correct
the whole time the list is short enough to fit one page. So it survives every manual check and breaks for the
users with the most rows.
Return it from the same endpoint as the page rather than adding a second one — one round trip, and a total the client cannot forget to ask for. The two statements still run against separate snapshots, so a write landing between them can move the total by a row; put both in one statement or one transaction where that matters:
// endpoint: the page and the total it belongs to, resolved together
const [items, [total]] = await Promise.all([findManyPage(), countMatchingRows()]);
return { paginationData: getCursorPaginationData(items, limit, sortBy), total: total?.count ?? 0 };
The store then holds the server's number and every optimistic write that changes it moves it under the same
rollback as the list — a delete decrements it, a clear-all zeroes it, and each rollback restores the previous
value alongside the previous rows. A write that gates on the loaded rows (items.every(...)) is the same bug one
layer down: gate on the total, which is the only value that speaks for the pages nobody has read.
A keyed read's query closure does not run on the hydrating client
readItems(query, { key }) adopts the payload on the hydrating render and never calls query there, so
anything the closure writes besides its returned page is written on the server only. That is fine — and the
reason the aggregate above may live in the closure — as long as the destination is Pinia store state, which
rides the payload and is restored on hydration. A closure that writes a module-level ref, a component ref or
anything else outside a store loses that value at hydration and leaves the surface rendering a default nobody
can explain. Anything read on the client goes in the store; the closure returns the page and writes store state,
nothing else.
Re-reading a List After a Push
A delivered push says "something arrived", never what: the tab re-reads the first page and works out which rows are new. Three rules make that reliable, and the first two come from the list being shared rather than owned by the push.
- Compare against the half the push writes, never the merged list. A surface that renders server rows alongside locally-created ones has two halves with different lifetimes; the newest row overall is routinely the local one, which has already been acted on. Snapshot the newest server row before the read, and act on the server rows past it.
- A timestamp watermark needs the ids alongside it. Postgres stores microseconds and a
Datekeeps milliseconds, so two rows written inside the same millisecond arrive with equal timestamps and a strict>drops the second one for good — it is not newer, and no later read will ever call it new again. Compare>=and exclude the ids the tab already held: the ids settle the ties, and the watermark still stops a page that simply grew (a tab holding fewer rows than a page) from replaying a backlog nobody was pushed. - Queue the re-reads, never run them side by side. Both comparisons snapshot the list before the read and
test against it after, so two pushes landing together snapshot the same list and both claim the row the first
read brought back. Put the whole snapshot-read-compare through
executeMutationunder one key — its per-key queue is what makes the second call read a list that already holds that row. Joining the in-flight read instead (isExclusive) is the wrong shape here: a row written after that read was issued would never arrive.
All three belong in the store, not the plugin or component that receives the push: only the owner of the list can tell its halves apart, and the receiver's job is to hand over the read.
Offline IndexedDB Cache — self-contained
The offline cache mirrors Pinia state and owns both directions itself, so nothing outside usePaginationCache touches it. Never call useOnline, readIndexedDb or writeIndexedDb from a feature read composable, and never add cache options to readItems/readMoreItems — hydration is already automatic, and a read that reaches for the cache is a second source of truth for what is loaded.
Deep Dives
references/search-as-you-type.md— when wiring a search input that queries the server as the user types, or changing one that already does.references/offline-cache.md— when a list must survive going offline, or when adding or altering a feature cache composable.
Signals
- GitHub stars
- 23
- Forks
- 3
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
pagination-esposter- Source
- github.com/esposter/esposter