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>() — it 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:
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 };
};
For global (non-room-scoped) lists omit the roomId parameter entirely.
Layer 3 — Component / Page
Call readFoos() in await at setup time, destructure hasMore + items via storeToRefs, and place <StyledWaypoint> at the bottom of the list. The waypoint emits change with an onComplete callback when it enters the viewport:
<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>
StyledWaypoint is placed inside the list container, after all items — it only triggers when :is-active is true, so it is safe to always render it.
Rules
- Never store a paginated list as a plain
ref<TItem[]>— always useCursorPaginationData<TItem>. - Never call
readItems/readMoreItemsfrom a component directly — always go through auseRead*composable. - Optimistic mutations update
items.valuedirectly (spread for create, filter for delete) — no need to re-fetch. readMoreItemsappends;readItemsresets the fullCursorPaginationDataref (handles navigating back to first page).- For multi-list pagination (e.g., messages per room) use
useCursorPaginationDataMapinstead ofuseCursorPaginationOperationData.
Vue Composable & Form Patterns (Esposter)
When to Use MaybeRefOrGetter vs Function Argument
Use MaybeRefOrGetter<T> when the composable internally reacts to the value — i.e., it's used inside a computed or watch. The composable needs to observe changes between calls.
Use a plain function argument on the returned function when the value is just a pass-through evaluated at call time. The composable has no internal reactive dependency on it.
// CORRECT: MaybeRefOrGetter — composable computes based on the value
export const useColumnNameRule = (columns: MaybeRefOrGetter<DataSource['columns']>) =>
computed(() => {
const columnsValue = toValue(columns) // reactive, re-evaluates on change
return (value: string) => columnsValue.some(...) ? 'exists' : true
})
// CORRECT: plain argument — value is just passed through at call time
export const useCopyToClipboard = () => {
return async (rowIds?: string[]) => { // rowIds evaluated at click time
await copyToClipboard(dataSource, item, rowIds)
}
}
Settings Tab Permissions — Hide at the Tab Level
Permission-gated settings tabs are hidden via SettingsPermissionMap, not guarded inside the tab component. Individual tab components never check permissions — they render unconditionally (the tab is simply not shown to users who lack permission).
Pattern:
- Add
[SettingsType.Xxx]: RoomPermission.YYYtoservices/message/settings/SettingsPermissionMap.ts LeftSideBar.vuefilters visible tabs usinghasPermissioninside acomputed- The tab component itself just fetches and renders — no
isPermittedcheck
// services/message/settings/SettingsPermissionMap.ts
export const SettingsPermissionMap: Partial<Record<SettingsType, RoomPermission>> = {
[SettingsType.Bans]: RoomPermission.BanMembers,
[SettingsType.AuditLog]: RoomPermission.ManageRoom,
};
// LeftSideBar.vue — filters entries via computed, no per-component checks
const visibleSettings = computed(() =>
Object.entries(SettingsListItemMap).filter(([settingsType]) => {
const permission = SettingsPermissionMap[settingsType];
if (!permission) return true;
const data = myPermissionsMap.value.get(roomId);
if (!data) return false;
return hasPermission(data.permissions, permission, data.isRoomOwner);
}),
);
Do NOT check isPermitted inside the tab component and show "Insufficient permissions" text — hide the tab entirely instead.
StyledWaypoint — Infinite Scroll Pattern
Use <StyledWaypoint> for cursor-paginated lists instead of a "Load more" button. It fires @change when scrolled into view and manages its own loading indicator via the default slot.
:is-active="hasMore"— hides and deactivates when no more pages@change="readMoreXxx"— the handler must accept(onComplete: () => void)and callonComplete()when done (via theonCompletearg toreadMoreItems)- Default slot: shown while loading (skeleton items); omit to use the built-in
v-progress-circular
<StyledWaypoint :is-active="hasMore" @change="readMoreBans" />
<!-- or with loading skeleton: -->
<StyledWaypoint :is-active="hasMore" @change="readMoreMembers">
<MessageModelMemberSkeletonItem v-for="i in DEFAULT_READ_LIMIT" :key="i" />
</StyledWaypoint>
Composable readMoreXxx signature must match the @change emitted callback:
const readMoreBans = (onComplete: () => void) =>
readMoreItems((cursor) => $trpc.moderation.readBans.query({ cursor, limit: LIMIT, roomId }), onComplete);
Never use a manual "Load more" v-btn with isLoadingMore state — that belongs to StyledWaypoint.
MaybeRefOrGetter Composables
When a composable argument should work with a plain value, a ref, or a getter function, type it as MaybeRefOrGetter<T> and unwrap with toValue().
Naming convention: const {name}Value = toValue({name}) — always suffix with Value.
// composables/tableEditor/file/useColumnNameRule.ts
import type { DataSource } from "#shared/models/tableEditor/file/DataSource";
export const useColumnNameRule = (columns: MaybeRefOrGetter<DataSource["columns"]>, currentName?: string) =>
computed(() => {
const columnsValue = toValue(columns);
return (value: string): string | true => {
if (value !== currentName && columnsValue.some(({ name }) => name === value)) return "Column already exists";
return true;
};
});
Callers pass a getter to stay reactive to prop changes:
const uniqueNameRule = useColumnNameRule(() => dataSource.columns, column.name); // edit
const uniqueNameRule = useColumnNameRule(() => dataSource.columns); // create
Rules:
- Don't explicitly annotate composable return types — let TypeScript infer. Only annotate if inference fails or a specific contract must be enforced.
- Vue auto-unwraps computed refs in templates, so
:rules="[uniqueNameRule]"correctly passes the function value.
Extract Duplicate Validation Rules
When the same validation rule appears in 2+ components, extract it to a shared composable immediately. Don't copy-paste. The currentName optional parameter handles the "allow own name" case for edit vs create:
// WRONG: Duplicate inline rules in EditDialogButton and CreateDialogButton
const uniqueNameRule = (v: string) => v === column.name || !columns.some(...) || 'Column already exists'
// RIGHT: Single composable, optional exclude parameter covers both edit and create
const uniqueNameRule = useColumnNameRule(() => dataSource.columns, column.name) // edit
const uniqueNameRule = useColumnNameRule(() => dataSource.columns) // create (no exclude)
Schema-Controlling Selectors in #prepend-form
When a dialog has a selector (e.g., column type, chart type) that controls which Vjsf schema is rendered, put it in the #prepend-form slot — not in the default slot alongside the schema content.
This matches the established pattern in Dashboard/Visual/Preview/EditFormDialog.vue:
<!-- WRONG: type selector mixed into default slot with Vjsf -->
<TableEditorFileEditDialogButton ...>
<v-select v-model="columnType" label="Type" ... />
<Vjsf v-model="editedColumn" :schema="jsonSchema" />
</TableEditorFileEditDialogButton>
<!-- RIGHT: type selector in #prepend-form, schema content in default -->
<TableEditorFileEditDialogButton ...>
<template #prepend-form>
<v-select v-model="columnType" label="Type" ... />
</template>
<v-text-field v-model="editedColumn.name" label="Column" ... />
<Vjsf v-model="editedColumn" :schema="jsonSchema" />
</TableEditorFileEditDialogButton>
The TableEditorFileEditDialogButton component exposes a #prepend-form slot rendered inside v-form before the default slot content.
Reactive Type-Switching with watch
When switching a form object's type (e.g., column type change), preserve unchanged fields (like name) and reset type-specific fields to defaults by constructing a new class instance:
const columnType = ref(ColumnType.String);
const editedColumn = ref<Column | DateColumn>(new Column());
watch(columnType, (newType) => {
const name = editedColumn.value.name; // preserve name
editedColumn.value = newType === ColumnType.Date ? new DateColumn({ name }) : new Column({ name, type: newType });
});
Note use of name (not currentName) to enable object shorthand { name }.
No structuredClone / toRawDeep on Freshly Newed Instances
Only call structuredClone(toRawDeep(...)) on data pulled from Vue reactive stores or refs. Freshly constructed class instances are already plain, non-reactive objects.
// WRONG: Unnecessary wrapping
const newRow = new Row({ data: { ... } })
executeAndRecord(new CreateRowCommand(index, structuredClone(toRawDeep(newRow))))
// RIGHT: Just pass the instance directly
const newRow = new Row({ data: { ... } })
executeAndRecord(new CreateRowCommand(index, newRow))
// CORRECT: Clone IS needed for data from reactive stores
const originalRow = structuredClone(toRawDeep(takeOne(editedItem.value.dataSource.rows, index)))
Unwrapping Reactive Proxies
- Always use
toRawDeepfrom@esposter/sharedinstead of Vue'stoRaw—toRawonly unwraps one level, whiletoRawDeeprecursively unwraps all nested reactive proxies. This is critical when passing reactive data to APIs that require plain objects (e.g. IndexedDBstore.put(),structuredClone, postMessage).
Resource Management
- Always clean up in
onUnmounted: intervals, timeouts, animation frames, event listeners. - Prefer
VueUsecomposables over manual event listeners where possible.
Online/Offline Detection
- Always use
useOnline()from VueUse — never usenavigator.onLinedirectly orgetIsServer()+navigator.onLineguards useOnline()returns a reactiveRef<boolean>that updates ononline/offlineevents- SSR-safe: defaults to
trueon the server (nonavigatoraccess, no crash) - For subscribables (tRPC subscriptions, WebSocket connections), use
useOnlineSubscribablewhich combinesuseOnline()+onMounted+watchImmediate+onUnmountedcleanup into a single composable — seecomposables/shared/useOnlineSubscribable.ts
Browser-Only Composables (SSR Safety)
Regular watch/watchDeep are SSR-safe — they don't fire until the source changes (which only happens client-side). Set them up directly in setup(), not inside onMounted. Vue automatically scopes them to the component and disposes them on unmount — no manual WatchHandle[] + onUnmounted cleanup needed.
export const useBrowserFeature = () => {
const someStore = useSomeStore();
const { someRef } = storeToRefs(someStore);
const
// Safe: watchDeep/watch only fire on changes (client-side)
watchDeep(someRef, (value) => {
// Safe to use indexedDB, etc. here
});
watch(someOtherRef, async (value) => {
if (!value || online.value) return;
// ...
});
};
watchImmediate is the SSR concern — it executes the callback during setup(), which runs on the server. If the callback accesses browser APIs, use watchTriggerable + onMounted to defer the first execution (see useOnlineSubscribable):
const { trigger } = watchTriggerable(source, (value) => {
// Browser-only logic
});
onMounted(async () => {
await trigger();
});
Dialog Data Loading
Do NOT re-fetch on every dialog open. Trust the Pinia store as the source of truth — CRUD operations flow through tRPC subscriptions which keep the store current automatically.
// WRONG: re-fetches every time the dialog opens
const { readFriends } = useReadFriends();
watchImmediate(isOpen, async (newIsOpen) => {
if (newIsOpen) await readFriends();
});
// CORRECT: fetch once on mount; subsequent opens use the cached store data
const { readFriends } = useReadFriends();
await readFriends();
The one-time await readFriends() in <script setup> handles the case where the user opens the dialog without having visited the friends page first. After that, the store stays fresh via subscriptions.
Subscribable Composables (use*Subscribables)
Composables that manage tRPC subscriptions for a feature are named use{Feature}Subscribables and live in composables/{domain}/subscribables/. They are self-registering (no return value) and called from the aggregating useSubscribables() composable.
// composables/message/subscribables/useVoiceSubscribables.ts
export const useVoiceSubscribables = async () => {
// calls useOnlineSubscribable, sets up tRPC subscriptions
// no return value
};
// composables/message/subscribables/useSubscribables.ts
export const useSubscribables = async () => {
await useRoomSubscribables();
await useVoiceSubscribables();
// ...
};
Rules:
- Name pattern:
use{Feature}Subscribables— notuse{Feature}Channel, notuse{Feature}Watcher - Location:
composables/{domain}/subscribables/ - No return value — composables that manage subscriptions are self-registering side effects
- Always call
useOnlineSubscribable(not rawwatch) so subscriptions are reconnected after going offline
Composable Rules
- Never use
createSharedComposable— VueUse'screateSharedComposablecreates global singletons that bypass Pinia's devtools, HMR, and reactive reset behavior. All shared reactive state must live in a Pinia store (defineStore). Composables that previously usedcreateSharedComposableshould be either replaced by a store entirely, or made thin wrappers that delegate to the corresponding store. - Single-function composables return the function directly — when a composable only exposes one function, return it directly instead of wrapping in an object:
return async (...) => { ... }. Callers useconst fn = useX()instead ofconst { fn } = useX(). Promise.resolve(value)for sync-to-async — when a sync expression needs to satisfy aPromise<T>return type, usePromise.resolve(value)instead ofasync () => value.
Type-Driven State Reset: Watch + Create Map
When a "discriminant" ref (type selector) changes and should reinitialize a related mutable ref, use watch with a create map that abstracts the per-type construction logic.
Step 1 — define a create map in services:
// ColumnTypeCreateMap.ts
export const ColumnTypeCreateMap = {
[ColumnType.Boolean]: { create: (name = "") => new Column<ColumnType.Boolean>({ name, type: ColumnType.Boolean }) },
[ColumnType.Date]: { create: (name = "") => new DateColumn({ name }) },
[ColumnType.Number]: { create: (name = "") => new Column<ColumnType.Number>({ name, type: ColumnType.Number }) },
[ColumnType.String]: { create: (name = "") => new Column({ name }) },
} as const satisfies Record<
Exclude<ColumnType, ColumnType.Computed>,
{ create: (name?: string) => DataSource["columns"][number] }
>;
Step 2 — use watch + map in the component:
const columnType = ref<Exclude<ColumnType, ColumnType.Computed>>(ColumnType.String);
const editedColumn = ref(structuredClone(ColumnTypeCreateMap[ColumnType.String].create()));
const resetForm = () => {
editedColumn.value = structuredClone(ColumnTypeCreateMap[columnType.value].create());
};
watch(columnType, (newType) => {
const name = editedColumn.value.name;
editedColumn.value = structuredClone(ColumnTypeCreateMap[newType].create(name));
});
For external sync (when a parent can reset the model): add a second watch on the discriminant field of the model to keep the local ref in sync.
const transformationType = ref(editedColumn.value.transformation.type);
watch(transformationType, (newType) => {
editedColumn.value.transformation = ColumnTransformationTypeCreateMap[newType].create();
});
watch(
() => editedColumn.value.transformation.type,
(newType) => {
transformationType.value = newType;
},
);
Notes:
- Always initialize the local type ref from the current model value, not a hardcoded default
if (newType === oldType) return;in a watch callback is always redundant — Vue only fires when the value changes
Offline IndexedDB Cache via readItems cacheOptions
Room-switch composables (useReadMessages, useReadMembers, useReadRooms) pass a ReadItemsCacheOptions<TItem> as the third argument to readItems. readItems in useCursorPaginationOperationData handles the if-online/offline logic centrally — no manual if (!online.value) branches in composables.
ReadItemsCacheOptions<TItem> (app/models/pagination/cursor/ReadItemsCacheOptions.ts):
interface ReadItemsCacheOptions<TItem> {
cache: {
read: (partitionKey: string) => Promise<TItem[]>;
write: (items: TItem[], partitionKey: string) => Promise<void>;
};
onCacheRead?: () => void; // fires only on offline cache hit
partitionKey: string;
}
readItems behavior:
- Offline +
cacheOptionspresent → read from cache, callonCacheRead?.(), skip tRPC query - Online → run tRPC query, then write results to cache
Generic base functions (app/services/cache/indexedDb/):
readIndexedDb(configuration, partitionKey)— reads all items for a partition keywriteIndexedDb(configuration, items, partitionKey)— replaces all items for a partition key (respectsconfiguration.limit)resetIndexedDb()— clears the singleton (used in tests)
Configuration objects (one per file, as const satisfies IndexedDbStoreConfiguration):
MessageIndexedDbStoreConfiguration—CompositeAzureKeyPath,limit: 50MemberIndexedDbStoreConfiguration—PartitionedIdKeyPathRoomIndexedDbStoreConfiguration—PartitionedIdKeyPath
partitionKey injection pattern — when the entity type lacks a partitionKey field (e.g. User, Room), inject/strip it in the cache.read/write lambdas:
cache: {
read: (partitionKey) => readIndexedDb(MemberIndexedDbStoreConfiguration, partitionKey),
write: (items, partitionKey) => writeIndexedDb(MemberIndexedDbStoreConfiguration, items, partitionKey),
},
useMessageCache — additional composable for messages only, because the message component doesn't re-mount on room switch:
watchDeep(items, ...)— writes to IndexedDB when live subscription adds messageswatch(currentRoomId, ...)— hydrates store from IndexedDB on offline room switch
Architecture doc: features/esbabbler/cache.md
Bundle Ancillary Reads with the Primary Read
When a component needs ancillary data (e.g. permissions, metadata) alongside a primary list load, bundle the ancillary read inside the primary read composable — not in the component's onMounted.
Rule: readMyPermissions and similar ancillary fetches belong inside the composable that owns the load (useReadRooms, useReadMembers, etc.), called in Promise.all alongside other metadata reads. If there is no natural companion read, call it directly in <script setup> — still no onMounted.
// WRONG: component fetches permissions separately in onMounted
const { isManageable, readMyPermissions } = roleStore;
onMounted(async () => {
if (!isCreator.value) await readMyPermissions({ roomId: room.id });
});
// CORRECT: bundled in the owning read composable alongside other metadata
// useReadRooms.ts
const readMyPermissions = useReadMyPermissions();
const readRooms = () =>
readItems(
() => $trpc.room.readRooms.query({ roomId: currentRoomId.value }),
({ items }) => {
const roomIds = items.map(({ id }) => id);
return Promise.all([readUsersToRooms(roomIds), readMyPermissions(roomIds)]);
},
);
Follow the useReadUsersToRooms pattern for batch ancillary reads — a composable that accepts an array of IDs and calls the store method for each in Promise.all.
- Writable computed is NOT the right tool here — it requires a backing
_refand still needs an external sync watch when a parent can reset the model
Async onClick Handlers — Always Use getSynchronizedFunction
When an onClick (or any event handler) calls an async function, always wrap with getSynchronizedFunction from #shared/util/getSynchronizedFunction. Never use void fn() or a bare () => { fn() }.
getSynchronizedFunction prevents concurrent calls (re-entrancy guard) and handles floating-promise lint errors in a single pattern.
import { getSynchronizedFunction } from "#shared/util/getSynchronizedFunction";
// WRONG — floating promise / no debounce
onClick: () => { void openThread(partitionKey, rowKey) }
// WRONG — swallows errors silently
onClick: () => { openThread(partitionKey, rowKey) }
// CORRECT
onClick: getSynchronizedFunction(async () => {
await openThread(partitionKey, rowKey);
}),
This applies everywhere an async function is used as an event handler: onClick, onChange, onSubmit, etc.
Source: Esposter/Esposter — distributed by TomeVault.