# Vue Composable Patterns

> 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.

- Skill: `tomevault-io/vue-composable-patterns` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add tomevault-io/vue-composable-patterns`
- Raw SKILL.md: https://api.skillmd.com/api/skills/tomevault-io/vue-composable-patterns/raw
- Safety review: pending (external: skill-scanner PASS, skillspector CAUTION)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: tomevault-io (https://skillmd.com/u/tomevault-io)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/tomevault-io/vue-composable-patterns

---


## 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`:

```ts
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:

```ts
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:

```vue
<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.

```typescript
// 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:**

1. Add `[SettingsType.Xxx]: RoomPermission.YYY` to `services/message/settings/SettingsPermissionMap.ts`
2. `LeftSideBar.vue` filters visible tabs using `hasPermission` inside a `computed`
3. The tab component itself just fetches and renders — no `isPermitted` check

```typescript
// 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 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`

```vue
<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:

```typescript
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`.

```typescript
// 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:**

```typescript
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:

```typescript
// 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`:

```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:

```typescript
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.

```typescript
// 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 `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.

```ts
export const useBrowserFeature = () => {
  const someStore = useSomeStore();
  const { someRef } = storeToRefs(someStore);
  const online = useOnline();

  // 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`):

```ts
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.

```typescript
// 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.

```typescript
// 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` — 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:**

```ts
// 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:**

```ts
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.

```ts
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`):

```typescript
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 + `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:

```typescript
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`.

```typescript
// 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 `_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.

```typescript
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](https://github.com/Esposter/Esposter) — distributed by [TomeVault](https://tomevault.io).
<!-- tomevault:4.0:skill_md:2026-05-11 -->

