| name | vue-composable-patterns |
| description | Esposter-specific Vue 3 composable and form patterns — MaybeRefOrGetter, SSR safety, online/offline, type-driven state reset, resource management, and cursor pagination (store + useRead* composable + StyledWaypoint). Apply when writing composables, form dialogs, paginated lists, or browser-aware reactive code. Use when this capability is needed. |
| metadata | {"author":"Esposter"} |
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>();
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 use CursorPaginationData<TItem>.
- Never call
readItems/readMoreItems from a component directly — always go through a useRead* composable.
- Optimistic mutations update
items.value directly (spread for create, filter for delete) — no need to re-fetch.
readMoreItems appends; readItems resets the full CursorPaginationData ref (handles navigating back to first page).
- For multi-list pagination (e.g., messages per room) use
useCursorPaginationDataMap instead of useCursorPaginationOperationData.
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.
export const useColumnNameRule = (columns: MaybeRefOrGetter<DataSource['columns']>) =>
computed(() => {
const columnsValue = toValue(columns)
return (value: string) => columnsValue.some(...) ? 'exists' : true
})
export const useCopyToClipboard = () => {
return async (rowIds?: string[]) => {
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.YYY to services/message/settings/SettingsPermissionMap.ts
LeftSideBar.vue filters visible tabs using hasPermission inside a computed
- The tab component itself just fetches and renders — no
isPermitted check
export const SettingsPermissionMap: Partial<Record<SettingsType, RoomPermission>> = {
[SettingsType.Bans]: RoomPermission.BanMembers,
[SettingsType.AuditLog]: RoomPermission.ManageRoom,
};
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 call onComplete() when done (via the onComplete arg to readMoreItems)
- 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.
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);
const uniqueNameRule = useColumnNameRule(() => dataSource.columns);
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:
const uniqueNameRule = (v: string) => v === column.name || !columns.some(...) || 'Column already exists'
const uniqueNameRule = useColumnNameRule(() => dataSource.columns, column.name)
const uniqueNameRule = useColumnNameRule(() => dataSource.columns)
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;
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.
const newRow = new Row({ data: { ... } })
executeAndRecord(new CreateRowCommand(index, structuredClone(toRawDeep(newRow))))
const newRow = new Row({ data: { ... } })
executeAndRecord(new CreateRowCommand(index, newRow))
const originalRow = structuredClone(toRawDeep(takeOne(editedItem.value.dataSource.rows, index)))
Unwrapping Reactive Proxies
- Always use
toRawDeep from @esposter/shared instead of Vue's toRaw — toRaw only unwraps one level, while toRawDeep recursively unwraps all nested reactive proxies. This is critical when passing reactive data to APIs that require plain objects (e.g. IndexedDB store.put(), structuredClone, postMessage).
Resource Management
- Always clean up in
onUnmounted: intervals, timeouts, animation frames, event listeners.
- Prefer
VueUse composables over manual event listeners where possible.
Online/Offline Detection
- Always use
useOnline() from VueUse — never use navigator.onLine directly or getIsServer() + navigator.onLine guards
useOnline() returns a reactive Ref<boolean> that updates on online/offline events
- SSR-safe: defaults to
true on the server (no navigator access, no crash)
- For subscribables (tRPC subscriptions, WebSocket connections), use
useOnlineSubscribable which combines useOnline() + onMounted + watchImmediate + onUnmounted cleanup into a single composable — see composables/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 online = useOnline();
watchDeep(someRef, (value) => {
});
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) => {
});
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.
const { readFriends } = useReadFriends();
watchImmediate(isOpen, async (newIsOpen) => {
if (newIsOpen) await readFriends();
});
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.
export const useVoiceSubscribables = async () => {
};
export const useSubscribables = async () => {
await useRoomSubscribables();
await useVoiceSubscribables();
};
Rules:
- Name pattern:
use{Feature}Subscribables — not use{Feature}Channel, not use{Feature}Watcher
- Location:
composables/{domain}/subscribables/
- No return value — composables that manage subscriptions are self-registering side effects
- Always call
useOnlineSubscribable (not raw watch) so subscriptions are reconnected after going offline
Composable Rules
- Never use
createSharedComposable — VueUse's createSharedComposable creates 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 used createSharedComposable should 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 use const fn = useX() instead of const { fn } = useX().
Promise.resolve(value) for sync-to-async — when a sync expression needs to satisfy a Promise<T> return type, use Promise.resolve(value) instead of async () => 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:
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>,
{ : [][] }
>;
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;
partitionKey: string;
}
readItems behavior:
- Offline +
cacheOptions present → read from cache, call onCacheRead?.(), 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 key
writeIndexedDb(configuration, items, partitionKey) — replaces all items for a partition key (respects configuration.limit)
resetIndexedDb() — clears the singleton (used in tests)
Configuration objects (one per file, as const satisfies IndexedDbStoreConfiguration):
MessageIndexedDbStoreConfiguration — CompositeAzureKeyPath, limit: 50
MemberIndexedDbStoreConfiguration — PartitionedIdKeyPath
RoomIndexedDbStoreConfiguration — 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 messages
watch(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.
const { isManageable, readMyPermissions } = roleStore;
onMounted(async () => {
if (!isCreator.value) await readMyPermissions({ roomId: room.id });
});
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
_ref and 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";
onClick: () => { void openThread(partitionKey, rowKey) }
onClick: () => { openThread(partitionKey, rowKey) }
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.