Appearance
Components
Vue components are the backbone of the application. Clear conventions ensure maintainability, readability, and predictable behavior across the codebase. This document defines how components should be designed, structured, and styled.
Component Types
Shared Components
- Reusable across different parts of the app.
- Usually UI-oriented (e.g.,
Button,Select,Modal). - Must remain dumb: no business logic, only render based on props and emit events.
Page Components
- Located in
src/pages/<Module>/. - Contain data fetching, business logic, and orchestration of child components.
- Auxiliary page-scoped components live in
src/pages/<Module>/components/. - If business logic grows complex, prefer extracting it into a page composable.
Rules
- Shared components and page components use composed names.
- Single-word names are reserved for shared UI primitives (
Button,Select). - Page components should have descriptive names.
- Single-word names are reserved for shared UI primitives (
- Page components must not trigger API calls in their children.
- Data flows down via props.
- Events flow up via emits.
- Prop drilling is preferable to event buses; it avoids unpredictable cross-component communication.
- Avoid Event Bus patterns — they create hard-to-track behavior.
- Avoid namespaced components (
<Namespace.Component />). They couple templates to directory structure and bypass Vue's resolver, undermining explicit imports and tree-shaking. Import child components explicitly instead. - Avoid custom directives—they hide behavior behind templates, complicate typing, and obscure the flow. Prefer composables or child components for reusable logic.
- Props and events should be preferred for parent-child component communication. Avoid mutating props.
- Keep conditional rendering minimal. If templates become dense with conditionals, extract the logic into smaller subcomponents.
Script & Syntax
- All components use
<script setup lang="ts">. - Vapor mode (Vue 3.6) is reserved for heavy components where profiling shows performance issues.
- Prefer function declarations over const functions (hoisting keeps them predictable).
- Const functions allowed only when dealing with higher-order patterns (function that returns a function).
- Use
defineExposeonly to surface component methods; never expose reactive state. If a parent must update child state, provide a child method instead of mutating the reactive source directly. - Never use top-level
awaitin<script setup>; it can introduce execution-time side effects. If a blocking async initialization is unavoidable, add a short comment explaining why and ensure the consumer wraps the component with<Suspense>. - Generate unique IDs with
useId(Vue 3.5+) when wiring ARIA attributes or<label for>pairs instead of manual counters or globals. - Use
defineOptions({ name: 'ComponentName' })for all components to ensure consistent DevTools labeling. - Avoid overuse of
reffor non-reactive data—prefer plain variables unless reactivity is needed. - Choose
watchEffectfor dependency tracking where explicit sources aren't needed; otherwise usewatchfor controlled observation. - Use
v-modelonly when the component is stateful by design. Keep it immutable for display-only components. - For component file naming rules, see 01.Naming. For composable consumption rules (no destructuring, reference through instance), see 04.Composables.
Logic Order
Every component follows a strict logic order inside <script setup>:
vue
<script setup lang="ts">
// Imports
// interface/types declarations
// - Each component must declare a Props interface/type (exported).
// - Follow naming rules from 01.Naming.md.
// - Additional domain types should be extracted to `.types.ts` files colocated with the component.
// configuration constants
// props
// emits
// composables instances
// non reactive constants (not configuration)
// inject usage
// component refs
// component reactive
// computed
// provide registrations
// watchers / watchEffect
// const functions
// functions
// - getter functions
// - actions
// - events
// lifecycle hooks
// defineExpose
</script>
<template></template>
<style scoped></style>Templates
- Avoid complex JS expressions in templates → move to a computed or function.
- Avoid assignments directly inside event bindings → use functions.
- Inline handlers are allowed for very simple actions.
- Create template refs with
useTemplateRef(Vue 3.5+) instead of manualref(null)patterns.
ts
// ✅ Preferred
const inputRef = useTemplateRef<HTMLInputElement>('input')- Prefer
v-ifoverv-showexcept when frequent toggling for performance (e.g., dropdown menus). v-ifandv-formust never coexist on the same element—split them across separate<template>wrappers.- Use
<template>as wrapper forv-forandv-ifblocks. - Add empty lines between
<template>fragments for readability. - Key is mandatory in v-for. Keys must combine intent + unique ID (in kebab-case).
- Example:
:key="`user-item-${user.id}`" - Avoid using array indexes unless list is static and immutable.
- Example:
- Break down complex templates into smaller components.
- Props and events in templates use kebab-case.
- Always prioritize accessibility: semantic HTML, ARIA attributes, and alt text.
- When forwarding attributes from parent to child, use
$attrscarefully and considerinheritAttrs: falsewhen the child manages all props directly. - Keep templates declarative and predictable — avoid nesting multiple ternaries or inline logic that hides intent.
Teleport
- Use
<Teleport to="body">when a component must render content outside its own DOM tree (modals, toasts, dropdown overlays). - Clean up teleported content in
onUnmountedto prevent orphaned DOM nodes. - Prefer a named teleport target (e.g.,
<Teleport to="#modal-root">) for testability and to avoid collisions between multiple teleported components.
Attribute Order Reference
Attribute order follows the sequence below to keep templates predictable.
- Definition →
isorv-is - List Rendering →
v-for - Conditionals →
v-if,v-else-if,v-else,v-show - Render Modifiers →
v-pre,v-once - Global Awareness →
id - Unique Attributes →
ref,key - Two-Way Binding →
v-model - Reactive Props → bound attributes (
:prop,v-bind) - Hardcoded Props → static prop values
- HTML Attributes → native attributes (
type,class,aria-*, etc.) - Events →
@event - Content Helpers →
v-html,v-text
Two-Way Data Binding
Avoid
defineModel()unless the component is truly two-way bound and reused across multiple parent contexts.
- Use
defineModelonly when the component exposes a reusable two-way surface; otherwise, favor the classicprop + emitpattern so data flow stays explicit. - Keep the returned binding named
modelValuewhenever you rely on the defaultv-model— this mirrors Vue’s expectations and prevents accidental aliasing (const modelValue = defineModel<T>()). - If you need additional two-way bindings, prefer refactoring to separate components or exposing controlled props/events. When duplication is unavoidable, prefix each additional binding with
modelso the relationship is obvious (e.g.,const modelFilters = defineModel<Filters>('filters')).- Avoid exposing more than two or three models — if more are needed, convert to explicit props and emits for clarity.
- When you name a binding (e.g.,
defineModel('filters')), ensure consumers use the matchingv-model:filtersmodifier. Mismatched names silently break synchronization between parent and child.
Styling
<style>blocks reserved for cases where utilities don’t cover the need.- Use scoped CSS by default.
- Prefer class selectors over element selectors in scoped styles, because large numbers of element selectors are slow.
- If using a CSS framework or utility library, prefer utility classes instead of writing custom CSS.
- Use CSS Modules only when scoped CSS limitations arise.
- Class Naming strategy: follow BEM principles.
- Prefer global modifiers if you must override parent-to-child styling.
- Never use
:global()on root selectors → it can cause style leaks. - Prefer explicit bindings (
:class,:style) over inline string concatenation. - For multiple dynamic classes, prefer object or array syntax:
vue
<!-- ✅ GOOD -->
<div :class="{ active, disabled }" />
<!-- ✅ GOOD -->
<div :class="[baseClass, modifier]" />
<!-- ❌ BAD -->
<div class="${isActive ? 'active' : ''} foo">- Keep selectors shallow — prefer class selectors over deep or element selectors for performance.
- Maintain formatting consistency with Prettier and ESLint Vue Plugin (see 10.Lint-Format).
Props
- Each component defines its own exported Props interface/type.
- Avoid inline typing in
defineProps. - Always provide validators and default values where applicable.
- Avoid prop explosion—more than six props is a sign to group related ones into objects or refactor logic.
- Do not mutate props or pass objects by reference. Clone when necessary.
- Avoid passing functions as props unless strictly necessary — justify with a comment.
- Avoid overly complex prop types (union overloads, large object shapes).
- Always validate emitted events via
defineEmitswith explicit signatures. - Keep prop identifiers camelCase in
<script setup>and pass them as kebab-case in templates; Vue handles the conversion, so mixing cases makes bindings fragile.
Events
- Always validate emitted events via
defineEmitsso the public surface is explicit and typed.tsDeclaring the surface this way keeps payloads discoverable, drives consumer auto-complete, and highlights breaking changes during review.const emit = defineEmits<{ 'update:modelValue': [value: string] submit: [] }>() - Event names must stay lowercase, action-like verbs (
save,close,submit) unless Vue reserves the contract (e.g.,update:modelValue). - When a component cannot rely on
defineModel, keep manualupdate:*emitters aligned with the function naming rules from 01.Naming (verb + noun, noon*prefixes):function emitFiltersUpdate(payload) { emit('update:filters', payload) }. - If you are tempted to emit unions through a single event, split the concern into purpose-driven events so payloads stay narrow and type-safe.
- Don’t emit mutable objects by reference—clone them before emitting to avoid the parent and child sharing accidental mutation channels.
Component Contracts & Reusability
- Favor composition over inheritance—avoid mixins or prototype extension.
- Use composables for shared logic instead of renderless components unless strictly necessary.
- Renderless components should have a clear naming suffix (
LogicorProvider) to indicate their role. - Keep component APIs minimal; expose only what consumers truly need.
Component Generics
- Declare generic parameters with the
genericattribute on<script setup>when the component is shared and its consumer controls prop types. - Avoid generics in page- or feature-specific components; tailor props directly instead.
- Limit components to a single generic parameter when possible—multiple generics are hard to maintain.
- Generic type parameters should start with
Tand be descriptive (e.g., TItem, TResponse).
vue
<!-- ✅ Shared typed list -->
<script setup lang="ts" generic="TItem">
defineProps<{ items: TItem[] }>()
</script>Dynamic Components
- Use
v-iswith a computed that returns the correct component. - Do not hardcode conditional component switches inside the template.
Slots
- Always declare slots with
defineSlotsto document names and props. - Keep slot API minimal; type slot props.
- Slot names use single words; if a composite name is required, use camelCase.
- When consuming slot props, destructure them in the template.
- Provide default slot content for predictable rendering.
ts
// ✅ Explicitly typed slots
defineSlots<{
default(props: { message: string }): void
action(props: { id: number }): void
}>()Dependency Injection (provide/inject)
- Type every injection with
InjectionKey<T>— never use string keys or inject raw untyped values. - Reserve provide/inject for plugin-level or cross-cutting dependencies (router, i18n, theme) and for component-library internal wiring.
- Prefer composables or Pinia stores for shared reactive state; avoid using provide/inject as a state container.
- Always provide a fallback in the consumer (
inject(key, defaultValue)or guard againstundefined) so components remain testable outside the expected hierarchy. - Keep the provider the single source of truth; don't replicate injected values in child state.
ts
// shared/injectionKeys.ts
import type { InjectionKey } from 'vue'
export const ToastKey: InjectionKey<{ show: (msg: string) => void }> = Symbol('toast')Third-Party Libraries
- Avoid libraries that don’t fully support Vue. Prefer ecosystem tools like VueUse.
- If integration is unavoidable, wrap the library in a shared component instead of injecting it directly.
- When using DOM-manipulating libraries, expose minimal ref-based APIs and clean up in
onUnmounted.
Performance & Abstractions
- Prefer templates over render functions.
- Avoid JSX — Vue SFCs are the standard.
- For large lists, virtualize rendering instead of creating thousands of DOM nodes.
- Avoid unnecessary abstractions:
- Renderless components and HOCs increase component instance count.
- Fine in small numbers, but in large lists, one abstraction may multiply into hundreds of instances.
- Consider virtualization or pagination when rendering >200 items in a single view.
- Only use render functions or abstractions when strictly necessary and justified.
Rationale
- Enforcing the
<script setup>ordering, banning top-levelawait, and limitingdefineExposeto methods keeps component logic deterministic, tree-shakeable, and safe to import anywhere. - Prop-down/event-up flow—combined with the ban on event buses, namespaced components, and ad-hoc directives—maintains clear ownership and prevents hidden side effects.
- Template guardrails (
useTemplateRef, keyed lists, simple handlers, accessibility-first) balance readability with maintainability and stop brittle inline logic from creeping in. - Requiring exported prop types, scoped generics, and typed slots via
defineSlotsaligns components with the TypeScript conventions. This protects consumers from type drift. - Styling conventions (utility-first preference, scoped CSS defaults, BEM naming) keep visual concerns modular and eliminate accidental global leaks.
- Guidance for dynamic components, third-party wrappers, and render/abstraction usage keeps performance workarounds deliberate instead of accidental regressions.