| name | vue |
| description | Esposter Vue 3 SFC conventions — macro ordering, template patterns, watch aliases, props naming, refs/computed, and conditional logic. Apply when writing .vue files. Use when this capability is needed. |
| metadata | {"author":"Esposter"} |
Vue Conventions
SFC Structure & Formatting
<script setup lang="ts"> at the top of every SFC.
- Always use
lang="scss" in Vue <style> blocks.
- Use self-closing tags for components/elements without content:
<Component />.
- No blank lines within Vue templates.
- No blank lines between
const assignments — group them tightly together.
- No blank line before
return when it immediately follows a const assignment in a small function.
- Composables that return a function directly: no blank line between the last
const assignment and the return — the return line immediately follows the last setup line with no gap.
- Avoid unnecessary comments — make variable names descriptive instead of explaining obvious logic. Keep comments that explain why (non-obvious decisions, disable reasons, intentional workarounds). No blank line before or after a comment — attach it directly to the code it describes.
- Minimise blank lines; group related code tightly.
- Blank line after a closing
} of an if, for, or other block statement — unless it is the last statement in its scope or is immediately followed by another opening block.
Vue Macro Ordering
defineSlots → defineModel → defineProps → defineEmits (in this order), then all const assignments, then defineExpose last (preceded by a blank line, before any watch/lifecycle hooks).
Inline Functions & Macros
- Inline arrow functions where argument types can be inferred from context — don't extract single-use, trivially-typed lambdas into named functions.
- Vue event handlers — write single-use handlers directly in the template (
@submit="async (_, onComplete) => { ... }"). This lets Vue infer event argument types automatically and avoids component-local names that add no reuse. Inline single-statement calls directly (@click="save(id)") and inline short multi-statement async handlers when they are only used once. Extract to a named const myHandler = async () => { ... } only when the logic is reused, exposed, passed to non-template APIs (timers, listeners, lifecycle hooks, third-party callbacks), depends on globals that Vue templates do not expose or type well (for example structuredClone, Promise, or browser/file picker APIs), uses syntax that Vue template expressions do not handle well (for example instanceof), or is long enough that inlining would make the template harder to scan.
- IME composition guard — when handling
@keydown.enter on text inputs, guard inline against IME composition so that confirming a CJK candidate doesn't prematurely commit: @keydown.enter.stop="!$event.isComposing && commitEdit()".
defineModel: always type explicitly. For booleans, you must pass { default: false } so the type does not implicitly include undefined (defineModel<boolean>({ default: false })).
defineProps: always destructure props at declaration time (const { id, name } = defineProps<Props>()). This preserves the props reactivity transform and avoids props.foo / .value ceremony in script and template. Use defaults in the destructure when needed.
defineSlots: only assign to a variable when slots is actually referenced in script — const slots = defineSlots<{ ... }>(). If slots is not used in script (e.g. the template uses <slot> tags directly), call defineSlots<...>() without assignment.
- Never destructure event parameters — always use
(event: KeyboardEvent) => { event.key ... } not ({ key }: KeyboardEvent) => { key ... }. Destructuring event methods (e.g. , ) causes "Illegal invocation" because they lose their binding. Keep the full object for consistency even when only accessing properties.
Template Attribute Ordering
Within any element or component tag, order attributes as follows:
v-model (or v-for + :key) — binding/iteration directives first
class — static class string (if any)
- UnoCSS attributify props — shorthand utility classes used as props (e.g.
ma-2, flex, flex-col)
- Component props with values —
:prop="value" or prop="string" (alphabetical within this group)
- Shorthand boolean props — bare prop names that default to
true (e.g. clearable, hide-details, single-line)
- Event handlers —
@event="..." last
Example:
<v-text-field
v-model="search"
ma-2
density="compact"
label="Search"
variant="outlined"
clearable
hide-details
@keydown.enter.stop="submit()"
/>
Template Conventions
-
Truthiness checks — use v-if="value" not v-if="value !== null". Explicit null/undefined comparisons are only needed when distinguishing between multiple falsy values (e.g. a number where 0 is valid, a boolean where false is meaningful, or when null vs undefined must be treated differently).
-
No bare function references in @event bindings — bare references forward the DOM/Vue event object as the first argument, which is almost always unintended. Use fn() for zero-arg calls and an explicit arrow function when arguments are needed:
<!-- CORRECT — zero-arg call, no event forwarding -->
@click="onSave()" @keydown.enter="onSave()"
<!-- CORRECT — arrow function when passing specific args -->
@complete="(scene, tilemap) => useCreateTilemapAssets(scene, tilemap)"
<!-- WRONG — forwards the click/keydown Event object as first arg -->
@click="onSave" @keydown.enter="onSave"
-
v-for destructuring — always destructure v-for bindings when properties are accessed in the template: v-for="{ value, icon, title } of items" not v-for="item of items" + item.value. Only keep a full reference when the whole object is needed (e.g. passed as a prop or stored in a ref). In that case, name the loop variable to match the prop it will be passed to, enabling :propName shorthand.
-
#activator always first — in components that use both #activator and other slots (e.g. v-tooltip, v-menu), always place the #activator template as the first child.
-
Slot names with dots always use dynamic binding — Vue does not support dots in static slot names, so Vuetify item slots always require the bracket syntax: #[item.drag], #[item.actions]. Only plain names without dots can be static (e.g. #top, #activator).
-
Always use : shorthand instead of v-bind:propName — write :disabled="..." not . The object-spread form also has a shorthand: use instead of .
Optional Refs — Omit the Initial Value
When a ref is initially undefined, do not pass undefined as the argument — just omit it. Vue's ref<T>() overload infers Ref<T | undefined> automatically:
const callRoomId = ref<string | undefined>(undefined);
const callRoomId = ref<string>();
The same applies to other nullable-initial refs: ref<User>(), ref<number>(), etc.
Refs & Computed
-
Template refs — always use useTemplateRef for both component and HTML element refs.
- Components:
useTemplateRef<InstanceType<typeof ComponentName>>("name")
- HTML elements:
useTemplateRef("container") — no explicit type annotation needed, Vue infers it. Use a generic semantic name like "container", never the element tag name (not "spanRef", not "divRef").
-
Sort at display time — apply .toSorted() inside the computed that feeds the template; never sort in store ingestion (readX, setX, mutation helpers). Stores hold data in natural order; components transform for display. Exception: when the sorted order must be sent to the backend (e.g. message pagination cursors), sort before the API call instead.
-
Computed for reused expressions — extract a computed (named to match the prop, e.g. title) when the same derived value is bound to two or more props. This enables the :propName shorthand for one binding and avoids repeating the expression: const title = computed(() => ...) → :title :tooltip-text="title". No need for a computed if the value is only used in one place.
-
Inline prop values — inline prop values directly in the template to take advantage of Vue TypeScript inference. Only extract to a computed when the same logic is reused in multiple places. Single-use derived values stay inline.
-
Map lookups over computed — when a value depends on an enum/discriminant key, use a Map[type] lookup directly in the template instead of a computed. If multiple properties are needed from the same map entry, use Map[type].value. Only fall back to computed when the same map lookup is duplicated in two or more places.
-
Writable computed over watch + local ref — when a local boolean ref is entirely derived from (and writes back to) a store value, replace both the ref and the watch with a writable computed. This eliminates the indirect trigger pattern and keeps the store as the single source of truth:
isUpdateMode = ();
(editingRowKey, {
(newEditingRowKey !== message.) ;
isUpdateMode. = ;
editingRowKey. = ;
});
isUpdateMode = ({
: editingRowKey. === message.,
: {
editingRowKey. = value ? message. : ;
},
});
Conditional Logic
When branching on a type/discriminant, use in this priority order:
- Map lookup —
Map[type] inline in template (preferred)
switch expression — use a switch in script when a map is impractical
if / else if / else — explicit branches for complex conditions
- Never chain standalone
if statements for mutually exclusive conditions. Always use else if / else or a switch.
Auth Session
Always pass useFetch as the argument to authClient.useSession() in Vue components. This makes better-auth use Nuxt's SSR-aware useFetch internally instead of its default fetch:
const { data: session } = await authClient.useSession(useFetch);
const { data: session } = await authClient.useSession();
Upsert Forms — Create vs Edit Mode
When a form component handles both create and edit, use an explicit isCreate prop (default false) rather than deriving mode from the presence of initialValues. The parent page knows the intent and passes is-create explicitly.
For local form state, use a single values ref over separate per-field refs:
interface PostUpsertFormProps {
initialValues?: Pick<Post, "description" | "title">;
isCreate?: boolean;
}
const { initialValues = { description: "", title: "" }, isCreate = false } = defineProps<PostUpsertFormProps>();
const values = ref(initialValues);
- Template binds to
values.title, values.description — Vue auto-unwraps the ref
- Emit passes
values directly (auto-unwrapped in template to the plain object)
isCreate drives button text: isCreate ? 'Post' : 'Edit Post'
- Create page passes
is-create; update page passes :initial-values — no is-create
The same isCreate?: boolean pattern applies to dialog buttons (e.g. CrudView/EditDialogButton) where it also skips the equality check that would otherwise disable the save button when form state matches the original.
After Finishing Code Changes
- Run
pnpm format from the repo root — formats all packages at once (~1.6s, oxfmt).
- Run
pnpm typecheck in packages/app as a background task — takes too long to block on. The user reviews results when ready.
Watch Decision Tree — When to Use (and When Not to Use) watch
Reach for watch only after exhausting these alternatives:
1. Read-only derived value → computed
If a value is entirely derived from existing reactive state and never independently set, use computed. No watch needed.
const displayName = ref("");
watchImmediate(
() => user.value?.name,
(name) => {
displayName.value = name ?? "";
},
);
const displayName = computed(() => user.value?.name ?? "");
2. Form state initialized from props/store → initialize the ref directly
When a component has local form state that starts from a prop or store value but is independently editable by the user, initialize the ref directly. Never use watchImmediate just to set an initial value — that is always a code smell.
const selectedCategoryId = ref<null | string>(null);
watchImmediate(
() => room.value?.categoryId,
(categoryId) => {
selectedCategoryId.value = categoryId ?? null;
},
);
const selectedCategoryId = ref(room.value?.categoryId ?? null);
If the source can change externally while the form is open (e.g. real-time collaboration), add a plain watch — but not watchImmediate:
const selectedCategoryId = ref(room.value?.categoryId ?? null);
watch(
() => room.value?.categoryId,
(categoryId) => {
selectedCategoryId.value = categoryId ?? null;
},
);
Prefer props-down when the parent is adjacent and already has the data. If the immediate parent already computes the value, pass it as a prop. The child initializes from the prop — no watch, no store duplication:
const { categoryId } = defineProps<Props>();
const selectedCategoryId = ref(categoryId);
Only pass through an intermediate generic router component (e.g. Content.vue that routes to all settings types) if the prop is truly shared by all children. If only one specific settings type needs it, keep the store read in the leaf component and just initialize the ref directly.
3. Reset form state when a dialog/menu opens → only if data changes externally
Ask: can the underlying data change between opens from an external source (WebSocket push, another tab, another user)?
- Yes →
watch the open boolean and reset on open
- No → just initialize the
ref once at setup; the watch is ceremony
watch(menu, (isOpen) => {
if (!isOpen) return;
selectedStatus.value = status.value;
statusMessage.value = message.value;
});
const selectedStatus = ref(status.value);
If the user opens → changes → closes without saving → reopens, they'll see their unsaved selection. That is usually acceptable (or even desirable — they indicated intent). The watch-to-reset pattern forces a reset on every open, which can feel punishing.
4. Bridging to external imperative APIs → watch is correct
Vue's reactivity cannot reach into Phaser, Three.js, Tiptap, Desmos, or any DOM-imperative API. watch is the correct bridge:
watch(isDark, (newIsDark) => {
calculator.updateSettings({ invertedColors: newIsDark });
});
5. Async side effects triggered by reactive state → watch is correct
Auto-save, API calls on throttled search, typing indicators — these are inherently imperative:
watch(throttledSearchQuery, async (newQuery) => {
const results = await search(newQuery);
initializePaginationData(results);
});
Summary
| Scenario | Pattern |
|---|
| Read-only derivation | computed |
| Form init from prop/store | ref(source.value) directly — never watchImmediate |
| Form reset on dialog/menu open | watch(dialog, (isOpen) => { if (!isOpen) return; ... }) |
| Two-way store binding | Writable computed (get/set) |
| External imperative API | watch |
| Async side effect | watch |
Watch Aliases
Prefer watchDeep(source, cb) over watch(source, cb, { deep: true }) and watchImmediate(source, cb) over watch(source, cb, { immediate: true }). When both flags are needed, use watchDeep(source, cb, { immediate: true }) (alphabetical: deep before immediate).
Prefer watch Over watchEffect
Always use watch with explicit dependencies instead of watchEffect. watchEffect tracks dependencies implicitly, making them hard to audit and prone to unexpected re-runs when unrelated reactive data changes.
watchEffect(() => {
if (!gem.value) return;
gem.value.material.roughnessMap = roughnessMap.value;
});
watch([gem, roughnessMap], ([newGem, newRoughnessMap]) => {
if (!newGem) return;
newGem.material.roughnessMap = newRoughnessMap;
});
For a prop dependency, wrap it in a getter: () => isActive.
Vue Hooks
- Always place
watch, onMounted, onUnmounted, and other Vue lifecycle hooks/watchers at the bottom of <script setup>, after all const assignments.
- Always put a blank line before them to visually separate them from regular
const assignments.
- Always wrap the callback in an explicit arrow function — never pass a function reference directly. This avoids scope/binding issues and prevents accidental argument forwarding:
onUnmounted(() => { reset(); }) not onUnmounted(reset).
- Vue
watch, onMounted, onUnmounted, and related lifecycle hooks support async callbacks in this codebase. Use async () => { await ... } directly for hook/watch work; do not wrap Vue hook/watch callbacks in getSynchronizedFunction.
- This applies everywhere —
.map(), .filter(), lifecycle hooks, JS event listeners, etc. Always use array.map((item) => fn(item)) not array.map(fn). Vue template @event bindings are handled separately in Template Conventions: use @click="fn()" (call expression), not @click="fn" (bare reference).
Browser Globals — Always Use window. Prefix
Always prefix browser-only globals with window. for clarity. This makes it explicit that the code is browser-only and won't run on the server:
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const pc = new RTCPeerConnection({ iceServers });
const ctx = new AudioContext();
const audio = new Audio();
const frame = requestAnimationFrame(cb);
cancelAnimationFrame(frame);
const stream = await window.navigator.mediaDevices.getUserMedia({ audio: true });
const pc = new window.RTCPeerConnection({ iceServers });
const ctx = new window.AudioContext();
const audio = new window.Audio();
const frame = window.requestAnimationFrame(cb);
window.cancelAnimationFrame(frame);
Standard built-ins available in all environments (Node.js + browser) do not need the window. prefix: Uint8Array, Map, Set, JSON, Promise, crypto, etc.
Routing
useRouter() for reactive contexts — use when reading route data inside a computed or watch (e.g. router.currentRoute.value.params.id in a computed), or when calling navigation methods (router.push, router.replace).
useRoute() for plain reads — use when reading params/query outside of a reactive context (e.g. inside a regular function or async handler).
Vuetify
See the vuetify skill for all Vuetify-specific conventions.
Source: Esposter/Esposter — distributed by TomeVault.