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
constassignments — group them tightly together. - No blank line before
returnwhen it immediately follows aconstassignment in a small function. - Composables that return a function directly: no blank line between the last
constassignment and thereturn— thereturnline 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 anif,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 namedconst 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 examplestructuredClone,Promise, or browser/file picker APIs), uses syntax that Vue template expressions do not handle well (for exampleinstanceof), or is long enough that inlining would make the template harder to scan. - IME composition guard — when handling
@keydown.enteron 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 includeundefined(defineModel<boolean>({ default: false })).defineProps: always destructure props at declaration time (const { id, name } = defineProps<Props>()). This preserves the props reactivity transform and avoidsprops.foo/.valueceremony in script and template. Use defaults in the destructure when needed.defineSlots: only assign to a variable whenslotsis actually referenced in script —const slots = defineSlots<{ ... }>(). Ifslotsis not used in script (e.g. the template uses<slot>tags directly), calldefineSlots<...>()without assignment.- Never destructure event parameters — always use
(event: KeyboardEvent) => { event.key ... }not({ key }: KeyboardEvent) => { key ... }. Destructuring event methods (e.g.preventDefault,stopPropagation) causes "Illegal invocation" because they lose theirthisbinding. Keep the fulleventobject for consistency even when only accessing properties. @clickshorthands — if a click handler is a single async call, use@click="myAsyncFn(args)"directly — no need to wrap inasync () => { await myAsyncFn(args) }.- Never declare
defineModelunless the value is actually used in script (e.g. in awatch,computed, or passed somewhere). Don't create a model just to forward it — use:prop+@eventinstead.
Template Attribute Ordering
Within any element or component tag, order attributes as follows:
v-model(orv-for+:key) — binding/iteration directives firstclass— 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"orprop="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"notv-if="value !== null". Explicit null/undefined comparisons are only needed when distinguishing between multiple falsy values (e.g. a number where0is valid, a boolean wherefalseis meaningful, or whennullvsundefinedmust be treated differently).No bare function references in
@eventbindings — bare references forward the DOM/Vue event object as the first argument, which is almost always unintended. Usefn()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-fordestructuring — always destructurev-forbindings when properties are accessed in the template:v-for="{ value, icon, title } of items"notv-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:propNameshorthand.#activatoralways first — in components that use both#activatorand other slots (e.g.v-tooltip,v-menu), always place the#activatortemplate 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 ofv-bind:propName— write:disabled="..."notv-bind:disabled="...". The object-spread form also has a shorthand: use:="object"instead ofv-bind="object".Never use
.valuein templates — Vue auto-unwraps refs in template expressions. Writingref.valuein a template accesses.valueon the already-unwrapped object (not on the ref), which is almost alwaysundefined. Writefn(ref)notfn(ref.value)..valueis only needed in<script setup>(outside template expressions).
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:
// WRONG — explicit undefined is redundant
const callRoomId = ref<string | undefined>(undefined);
// CORRECT — omit the argument; type is Ref<string | undefined>
const callRoomId = ref<string>();
The same applies to other nullable-initial refs: ref<User>(), ref<number>(), etc.
Refs & Computed
Template refs — always use
useTemplateReffor 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").
- Components:
Sort at display time — apply
.toSorted()inside thecomputedthat 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:propNameshorthand 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
computedwhen 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, useMap[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
refand thewatchwith a writablecomputed. This eliminates the indirect trigger pattern and keeps the store as the single source of truth:// WRONG — local ref + watch as indirect trigger const isUpdateMode = ref(false); watch(editingRowKey, (newEditingRowKey) => { if (newEditingRowKey !== message.rowKey) return; isUpdateMode.value = true; editingRowKey.value = undefined; }); // CORRECT — writable computed; no watch needed const isUpdateMode = computed({ get: () => editingRowKey.value === message.rowKey, set: (value) => { editingRowKey.value = value ? message.rowKey : undefined; }, });
Conditional Logic
When branching on a type/discriminant, use in this priority order:
- Map lookup —
Map[type]inline in template (preferred) switchexpression — use aswitchin script when a map is impracticalif / else if / else— explicit branches for complex conditions- Never chain standalone
ifstatements for mutually exclusive conditions. Always useelse if/elseor aswitch.
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:
// CORRECT — SSR-aware
const { data: session } = await authClient.useSession(useFetch);
// WRONG — skips Nuxt's useFetch, breaks SSR
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
valuesdirectly (auto-unwrapped in template to the plain object) isCreatedrives button text:isCreate ? 'Post' : 'Edit Post'- Create page passes
is-create; update page passes:initial-values— nois-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 formatfrom the repo root — formats all packages at once (~1.6s, oxfmt). - Run
pnpm typecheckinpackages/appas 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.
// WRONG — watch + local ref for read-only derivation
const displayName = ref("");
watchImmediate(
() => user.value?.name,
(name) => {
displayName.value = name ?? "";
},
);
// CORRECT
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.
// WRONG — watchImmediate to initialize is redundant; ref starts as null then immediately overwritten
const selectedCategoryId = ref<null | string>(null);
watchImmediate(
() => room.value?.categoryId,
(categoryId) => {
selectedCategoryId.value = categoryId ?? null;
},
);
// CORRECT — initialize directly; no watch needed
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:
// Parent passes :category-id="room?.categoryId ?? null"
// Child:
const { categoryId } = defineProps<Props>();
const selectedCategoryId = ref(categoryId); // no watch, no store read
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 →
watchthe open boolean and reset on open - No → just initialize the
refonce at setup; the watch is ceremony
// ONLY justified if status can change from an external source (e.g. WebSocket)
watch(menu, (isOpen) => {
if (!isOpen) return;
selectedStatus.value = status.value;
statusMessage.value = message.value;
});
// If this component is the only mutation path, skip the watch entirely:
const selectedStatus = ref(status.value); // initialized once; fine
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.
// WRONG — implicit tracking, hard to audit
watchEffect(() => {
if (!gem.value) return;
gem.value.material.roughnessMap = roughnessMap.value;
});
// CORRECT — explicit dependencies
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 allconstassignments. - Always put a blank line before them to visually separate them from regular
constassignments. - 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(); })notonUnmounted(reset). - Vue
watch,onMounted,onUnmounted, and related lifecycle hooks support async callbacks in this codebase. Useasync () => { await ... }directly for hook/watch work; do not wrap Vue hook/watch callbacks ingetSynchronizedFunction. - This applies everywhere —
.map(),.filter(), lifecycle hooks, JS event listeners, etc. Always usearray.map((item) => fn(item))notarray.map(fn). Vue template@eventbindings 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:
// WRONG
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);
// CORRECT
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 acomputedorwatch(e.g.router.currentRoute.value.params.idin acomputed), 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.