Files
ForgeLM/.agents/rules/vue-idioms-and-patterns.md
T

13 KiB
Raw Blame History

trigger, description
trigger description
model_decision When writing Vue 3 code, working on Vue components, using Pinia stores, or building Vue composables

Vue Idioms and Patterns

Core Philosophy

Vue 3 Composition API is the default for all new code. <script setup> is the canonical syntax. Think in terms of reactive data flows, not component lifecycle hooks. Composables (use* functions) are the primary unit of logic reuse.

Scope: This file covers Vue 3 coding idioms for components, stores, and composables. For TypeScript type system patterns, see typescript-idioms-and-patterns.md. For file and folder layout, see project-structure-vue-frontend.md. For test naming, see testing-strategy.md. For logging, see logging-and-observability-principles.md.


<script setup> — The Only Style

Always use <script setup lang="ts">. Never use the Options API or the class-style component pattern for new code.

<!-- ✅ Canonical style -->
<script setup lang="ts">
import { ref, computed } from 'vue';

const props = defineProps<{ title: string; count?: number }>();
const emit = defineEmits<{ 'update:count': [value: number] }>();

const doubled = computed(() => (props.count ?? 0) * 2);
</script>

<!-- ❌ Options API — do not use for new components -->
<script lang="ts">
export default { props: { title: String }, ... }
</script>

Reactivity: ref vs reactive

Use When
ref<T>() Primitives, single values, values that may be reassigned
reactive() Plain objects where you always access properties (never reassign the whole object)
readonly() Expose state that must not be mutated outside its owner
// ✅ ref for primitives and replaceable objects
const count = ref(0);
const user = ref<User | null>(null);
user.value = fetchedUser; // reassignment is fine

// ✅ reactive for objects where you destructure properties
const form = reactive({ title: '', priority: 'medium' });

// ❌ Never destructure a reactive object — reactivity is lost
const { title } = form; // title is now a plain string, NOT reactive
// ✅ Use toRefs if you must destructure
const { title } = toRefs(form);

Computed Properties

  1. Use computed for all derived state — never recompute in the template

    // ✅ Cached, reactive
    const filteredTasks = computed(() =>
        tasks.value.filter(t => t.status === activeFilter.value)
    );
    
    // ❌ Recomputes on every render
    // <template>{{ tasks.filter(t => t.status === filter) }}</template>
    
  2. Never cause side effects inside computed — computed must be pure

    // ❌ Side effect in computed
    const count = computed(() => {
        taskStore.logAccess(); // NO — this is a side effect
        return tasks.value.length;
    });
    
  3. Use writable computed for two-way bindings

    const modelValue = computed({
        get: () => props.modelValue,
        set: (val) => emit('update:modelValue', val),
    });
    

Watch Strategy

Use the most precise watcher for the situation — over-watching is a performance and correctness problem.

Watcher Use When
watchEffect Side effect that should re-run whenever any of its reactive dependencies change; auto-tracks dependencies
watch You need the old value, lazy execution, or want to watch a specific source explicitly
computed You need a synchronous derived value (prefer this over watch for transformation)
// ✅ watchEffect — auto-tracks dependencies
watchEffect(() => {
    document.title = `Tasks (${count.value})`;
});

// ✅ watch — explicit source, has old value
watch(userId, async (newId, oldId) => {
    if (newId !== oldId) await loadUser(newId);
}, { immediate: true });

// ❌ Avoid using watch just for computed values
watch(tasks, () => { filteredCount.value = tasks.value.filter(...).length; });
// ✅ Use computed instead
const filteredCount = computed(() => tasks.value.filter(...).length);

Pinia Stores

The store directory structure is defined in project-structure-vue-frontend.md. This section covers Pinia coding idioms.

  1. Use the Setup Store API (not Options API) for new stores

    // task/store/task.store.ts
    export const useTaskStore = defineStore('task', () => {
        // State
        const tasks = ref<Task[]>([]);
        const isLoading = ref(false);
    
        // Getters (computed)
        const completedTasks = computed(() =>
            tasks.value.filter(t => t.status === 'done')
        );
    
        // Actions
        async function loadTasks() {
            isLoading.value = true;
            try {
                tasks.value = await taskAPI.getTasks();
            } finally {
                isLoading.value = false;
            }
        }
    
        return { tasks, isLoading, completedTasks, loadTasks };
    });
    
  2. Never mutate store state from outside the store

    // ❌ Direct mutation from a component
    const store = useTaskStore();
    store.tasks.push(newTask); // NO
    
    // ✅ Call an action
    await store.addTask(newTask);
    
  3. Inject the API dependency — never import it directly inside the store

    // ✅ Receives the API interface — testable with createTestingPinia + mock API
    export const useTaskStore = defineStore('task', () => {
        const api = inject<TaskAPI>(TASK_API_KEY);
        if (!api) throw new Error('[TaskStore] TASK_API_KEY not provided — ensure app.provide() is called before store access');
        // ...
    });
    
  4. Use storeToRefs when destructuring a store in components

    // ✅ Preserves reactivity
    const { tasks, isLoading } = storeToRefs(useTaskStore());
    const { loadTasks } = useTaskStore(); // actions don't need storeToRefs
    

Composables (use* Functions)

Composables are the Vue equivalent of custom hooks — self-contained, reusable units of reactive logic.

  1. Naming: always prefix with use

    • useTaskFilters, useAuth, usePagination
  2. Return reactive refs, not raw values

    // ✅ Caller can use returned values reactively
    function useCounter(initial = 0) {
        const count = ref(initial);
        const increment = () => count.value++;
        return { count, increment };
    }
    
    // ❌ count is a plain number — not reactive
    function useCounter() {
        let count = 0;
        return { count };
    }
    
  3. Always clean up side effects in onUnmounted

    function useWindowResize() {
        const width = ref(window.innerWidth);
        const handler = () => (width.value = window.innerWidth);
    
        onMounted(() => window.addEventListener('resize', handler));
        onUnmounted(() => window.removeEventListener('resize', handler)); // ✅ cleanup
        return { width };
    }
    
  4. Template refs with useTemplateRef (Vue 3.5+) — type-safe, IDE-friendly replacement for ref(null)

    // ✅ Vue 3.5+ — useTemplateRef provides fully typed access
    const inputEl = useTemplateRef<HTMLInputElement>('myInput');
    // <input ref="myInput" />
    
    // ❌ Old pattern (before 3.5) — less type-safe
    const inputEl = ref<HTMLInputElement | null>(null);
    
  5. Feature-specific composables live inside the feature directory — global composables go in src/composables/. See project-structure-vue-frontend.md.


Component Design

  1. defineProps with TypeScript generics — no runtime validators for typed props

    const props = defineProps<{
        taskId: string;
        variant?: 'compact' | 'full';
    }>();
    
    // Defaults via withDefaults
    const props = withDefaults(defineProps<{ variant?: 'compact' | 'full' }>(), {
        variant: 'full',
    });
    
  2. defineEmits with typed event signatures

    const emit = defineEmits<{
        'update:modelValue': [value: string];
        'submit': [task: CreateTaskRequest];
    }>();
    
  3. v-model contract: always modelValue prop + update:modelValue emit

  4. defineExpose to selectively expose methods to parent refs

    // Everything in <script setup> is private by default.
    // Use defineExpose only for intentional parent access (e.g., form.reset()).
    defineExpose({ reset, focus });
    // ❌ Without defineExpose: parent ref.value.reset() will be undefined
    
  5. v-bind="$attrs" and inheritAttrs: false for forwarding attributes

    // Avoid prop drilling for HTML attributes — forward them to the root element
    defineOptions({ inheritAttrs: false });
    // In template: <input v-bind="$attrs" />
    
  6. One concern per component — if the template exceeds 100 lines (excluding boilerplate), extract a sub-component

  7. Never put business logic in the template — computed and composables belong in <script setup>


Template Patterns

  1. Always bind :key with stable, unique IDs in v-for — never use index as key when list order can change

    <!-- ✅ Stable key -->
    <TaskCard v-for="task in tasks" :key="task.id" :task="task" />
    
    <!-- ❌ Index key — causes rerender bugs when list reordered -->
    <TaskCard v-for="(task, i) in tasks" :key="i" :task="task" />
    
  2. Never combine v-if and v-for on the same element — wrap with <template>

    <!-- ✅ -->
    <template v-for="task in tasks" :key="task.id">
        <TaskCard v-if="task.visible" :task="task" />
    </template>
    

Route Transitions

When using <Transition> or <RouterView> with transition effects, CSS frameworks that use @layer (Tailwind v4, Open Props, UnoCSS) can silently break SPA navigation by overriding transition properties in the cascade. This causes transitionend to never fire, permanently blocking the entering component.

  1. Avoid mode="out-in" when using @layer-based CSS frameworks — the leaving component's transitionend event may never fire, blocking the entering component indefinitely. Use simultaneous transitions instead:

    <!-- ❌ Dangerous with @layer CSS frameworks -->
    <Transition name="fade" mode="out-in">
      <component :is="Component" />
    </Transition>
    
    <!-- ✅ Safe: simultaneous leave/enter, always mounts new component -->
    <Transition name="fade">
      <component :is="Component" :key="$route.path" />
    </Transition>
    
  2. Always bind :key="$route.path" on dynamic <component> inside <Transition> — forces Vue to treat each route as a distinct component instance, ensuring proper enter/leave lifecycle

  3. Use !important on route transition CSS classes — guarantees transition properties win the @layer cascade:

    .fade-enter-active {
      transition: opacity 0.15s ease-in !important;
    }
    .fade-leave-active {
      transition: opacity 0.15s ease-out !important;
      position: absolute !important;
      width: 100% !important;
      top: 0 !important;
      left: 0 !important;
    }
    .fade-enter-from,
    .fade-leave-to {
      opacity: 0 !important;
    }
    
  4. Give the transition parent position: relative — contains the absolutely-positioned leaving element during the simultaneous transition overlap

For full diagnosis steps when a transition-stuck blank screen occurs, see the Debugging Protocol's Frontend module § CSS × Animation.


Testing

Test naming and pyramid proportions are defined in testing-strategy.md. This section covers Vue-specific tooling.

  1. Use createTestingPinia to stub stores in component tests

    import { vi } from 'vitest';
    
    const wrapper = mount(TaskView, {
        global: {
            plugins: [createTestingPinia({ createSpy: vi.fn })],
        },
    });
    
  2. Test component behaviour, not implementation details — query by accessible role, not CSS class

  3. Test stores independently — use setActivePinia(createPinia()) in store unit tests


Linting and Type Checking

Tool Purpose
vue-tsc --noEmit Full-template type checking
eslint-plugin-vue Vue-specific lint rules
prettier Canonical formatting

See code-completion-mandate.md for exact commands.


  • Code Idioms and Conventions @code-idioms-and-conventions.md
  • TypeScript Idioms and Patterns @typescript-idioms-and-patterns.md
  • Project Structure — Vue Frontend @project-structure-vue-frontend.md
  • Architectural Patterns — Testability-First Design @architectural-pattern.md
  • Testing Strategy @testing-strategy.md
  • Logging and Observability Principles @logging-and-observability-principles.md