AI News Hub Logo

AI News Hub

Cursor Rules for Vue.js: Composition API Patterns That Scale

DEV Community
Olivia Craft

Cursor Rules for Vue.js: Composition API Patterns That Scale Vue 3 has been stable for years, is the default template, Pinia replaced Vuex, and defineModel killed update:modelValue boilerplate. None of that is evenly distributed across the corpus your AI assistant trained on. So when you let Cursor or Claude Code generate Vue, you get an awkward dialect: Composition API mixed with data() blocks, props declared as untyped string arrays, watchers used where computed properties belong, Pinia stores written like Vuex modules, and v-for rendering with the array index as the key. The fix is not nicer prompts. It is rules the AI must follow on every generation, checked into the repo as .cursorrules. Eight rules below cover the patterns where Vue projects leak quality at scale: Composition API discipline, syntax, typed defineProps, composable extraction, Pinia store conventions, v-for key correctness, async component loading, and Vitest structure. Two include full before/after diffs. The complete .cursorrules file is at the bottom. The most common Vue 3 codebase failure mode is half Composition API, half Options API. New components get , but the moment Cursor hits anything tricky it falls back to defineComponent({ data, methods, computed }) because that pattern dominated training data for years. The rule: Use the Composition API exclusively. New components must use . Never generate Options API code: no defineComponent({ data, methods, computed, watch }), no `this`, no mixins. State goes in ref/reactive, derived values in computed, side effects in watch/watchEffect, lifecycle in onMounted/onUnmounted. If asked to modify an Options API component, port it to Composition API in the same change unless the user explicitly says otherwise. The "port it on contact" clause is what matters. Without it, AI extends Options API components forever and the migration never finishes. Syntax Discipline — Macros, Imports, Order looks like a free-for-all to AI assistants: import after defineProps, defineEmits halfway down the file, sometimes wrapped in a stray defineComponent. The macros are compiler-only, and there is a single correct shape for the file. The rule: Inside , declare in this order: 1. Imports. 2. Compiler macros: defineProps, defineEmits, defineModel, defineSlots, defineExpose. Never import them — they are globals. 3. Reactive state (ref/reactive/shallowRef). 4. Derived values (computed). 5. Functions (handlers, helpers). 6. Watchers and lifecycle hooks (watch, watchEffect, onMounted). Never wrap a body in defineComponent. Always lang="ts". Use for components parameterized over a type. The ordering makes diffs reviewable. When every component looks the same, "what changed" jumps off the page. defineProps With Generics, Defaults, and Validation The single most common Vue bug AI introduces is props typed as any. It writes defineProps(['title', 'items']) and your component loses every refactor-safety guarantee TypeScript gives you. When AI does add types, it often skips withDefaults and uses || fallbacks in the template — which breaks for boolean and number props because 0 || 10 === 10. The rule: Declare props with the type-only generic form: defineProps(). Never the runtime array or untyped object form. Required props are unmarked; optional use `?`. Defaults via withDefaults() — never `||` in the template (boolean and number props break: `0 || 10 === 10`). withDefaults takes a factory for object/array defaults. Never mutate a prop — use a local ref or computed. Type function props as (arg: T) => void, not Function. Here is what changes when this rule is in place. Before — Cursor's default output, no rule: const props = defineProps(['title', 'items', 'maxVisible', 'onSelect']) function visible() { return props.items.slice(0, props.maxVisible || 10) } {{ props.title }} {{ item.label }} props.items is any. props.maxVisible || 10 silently turns 0 into 10. onSelect could be anything, including undefined. Nothing here would survive a refactor of Item. After — same component with the rule applied: import { computed } from 'vue' interface Item { id: string label: string } const props = withDefaults( defineProps void }>(), { maxVisible: 10 }, ) const visible = computed(() => props.items.slice(0, props.maxVisible)) {{ props.title }} {{ item.label }} Item flows through everything, maxVisible: 0 works, the template now reads a memoized computed, and the :key is stable. Four bugs gone, one rule. Cursor's instinct is to inline everything in the component that needs it. Ask for a debounced search and you get a setTimeout ref, a watcher, an onUnmounted cleanup, and a fetch — all wedged into a 200-line component. Reuse on the next page, code duplicates. The rules: name it use*, return refs (not .value), no side effects at import time, clean up in onScopeDispose. The rule: Extract reusable reactive logic into composables under src/composables/, one per file, named useThing.ts. A composable takes plain args, returns an object of refs and functions, and never returns unwrapped .value. Side effects start inside the body, not at module import. Cleanup uses onScopeDispose so the composable works in both components and effectScope. Other composables are called at the top of the function, never conditionally. One concern per composable (data fetching, form state, keyboard shortcuts, geolocation). Combine them at the call site. "Never returns unwrapped .value" is the clause that makes destructuring at the call site safe, and the one Cursor breaks most often when it tries to "simplify" a return. defineStore Setup Syntax, storeToRefs at the Boundary Pinia is where Vue 2 muscle memory leaks worst. AI generates Vuex-style stores with state/getters/actions blocks because that pattern dominated 2018–2022 documentation. Pinia supports that "options" syntax, but setup syntax is what scales — same shape as a composable, full type inference, no this.commit('SET_FOO', 1). The other failure mode: destructuring a store directly, which strips reactivity exactly like destructuring a reactive() object. The rule: Define every store with setup syntax: defineStore('name', () => { ... return { state, getters, actions } }). Never use the options syntax with state/getters/actions blocks. State = ref/reactive. Getters = computed(). Actions = plain (or async) functions — no Vuex-style mutations. When consuming, destructure state and getters through storeToRefs(store) to preserve reactivity. Actions destructure directly off the store. Never `const { x } = useThingStore()` — it strips reactivity. One store per file under src/stores/, named useThingStore, with defineStore id matching the file name. Cross-store deps go through useOtherStore() inside actions, never at module top level. The second diff: Before — options-style store, broken destructuring at the call site: // stores/counter.ts import { defineStore } from 'pinia' export const useCounterStore = defineStore('counter', { state: () => ({ count: 0, lastUpdated: null as Date | null }), getters: { double: (state) => state.count * 2, }, actions: { increment() { this.count++ this.lastUpdated = new Date() }, }, }) import { useCounterStore } from '@/stores/counter' const store = useCounterStore() const { count, double } = store {{ count }} (×2 = {{ double }}) +1 count and double are plain numbers. The template renders the initial values forever. Click the button, the store updates, the view does not. There is no warning. After — setup-syntax store, storeToRefs at the boundary: // stores/counter.ts import { ref, computed } from 'vue' import { defineStore } from 'pinia' export const useCounterStore = defineStore('counter', () => { const count = ref(0) const lastUpdated = ref(null) const double = computed(() => count.value * 2) function increment() { count.value++ lastUpdated.value = new Date() } return { count, lastUpdated, double, increment } }) import { storeToRefs } from 'pinia' import { useCounterStore } from '@/stores/counter' const store = useCounterStore() const { count, double } = storeToRefs(store) const { increment } = store {{ count }} (×2 = {{ double }}) +1 State stays reactive. Actions destructure directly because they are not reactive values. The store body reads like a composable, so the same review checklist applies. v-for Key Discipline — Stable IDs, Never Index, Never Skipped The :key warning is famous and AI ignores it constantly. Two patterns break: :key="index" from v-for="(item, index) in items", and with the key on the wrong element. Index keys silently corrupt local state — toggle a checkbox in row 2, delete row 1, the checkbox state stays in row 2's now-shifted slot. needs the key on the , not the child. The rule: Every v-for must have a :key with a stable unique id from the data (typically item.id). Never use the loop index. Never use rendered content (item.name) unless guaranteed unique and immutable. For , the :key goes on the , not the child. Nested v-for: each loop gets a key from its own scope. If no natural id exists, generate one at data-load time with crypto.randomUUID and store it on the item. Never compute keys in the template — that recreates them every render and defeats the point. defineAsyncComponent for Routes and Heavy UI Only defineAsyncComponent is great for the right thing: splitting a heavy chart library, lazy-loading a modal, code-splitting a route. AI overuses it — wrapping every import "to be safe" — turning a 5-component page into 5 network round-trips and a flash of loading skeletons. The rule: Use defineAsyncComponent only for: 1. Route entries (prefer the router's `() => import('./View.vue')` form over wrapping with defineAsyncComponent in route configs). 2. Heavy components rendered behind a v-if (chart, editor, file picker). 3. Components that import a large dependency the rest of the page does not need. Always provide loadingComponent, errorComponent, delay (~200ms to avoid flashing on fast networks), and timeout. Use only for components with top-level await in ; never as a generic loading wrapper. Never wrap an always-visible child in defineAsyncComponent. The lines exist because AI treats it as a universal loading boundary. It is specifically for components with top-level await; using it elsewhere causes hydration mismatches in SSR. Vue test code rots faster than the components it tests when AI writes it. The patterns that cause the rot: a wrapper shared via beforeEach and mutated across tests, snapshots used as a substitute for assertions, reaching into wrapper.vm for private state, and mocking the entire Pinia store instead of using its testing helper. The rule: Test files live next to the component as Component.spec.ts using Vitest + @vue/test-utils. Call mount() inside each test body — never share a wrapper across tests via beforeEach. Assert on rendered DOM (wrapper.text(), wrapper.find) and emitted events (wrapper.emitted('event')). Never assert wrapper.vm internals — if a test needs it, it is public API and belongs in defineExpose. Pinia: createTestingPinia() with vi.fn() action stubs. Router: createRouter + createMemoryHistory. Use shallowMount only when stubbing children is the point of the test; otherwise mount(). Snapshots are for serialized data (API responses, generated text), never for component HTML — assert specific behaviors instead. Follow arrange / act / assert with blank lines between phases. Names describe behavior: "emits submit with form data when the submit button is clicked", not "test submit". .cursorrules File Drop this in the repo root. Cursor and Claude Code both pick it up. To split into .cursor/rules/*.mdc, one rule per file — the headings below map directly. # Vue 3 — Composition API Patterns ## API Choice - Composition API only with . No Options API. - Modifying an Options API component? Port it in the same change. ## Structure - Order: imports, macros (defineProps/Emits/Model/Slots/Expose), state, computed, functions, watch/lifecycle. - Macros are globals — never import them. Always lang="ts". Use generic="T" for generic components. ## Props - Type-only generics: defineProps(). Never array form. - Defaults via withDefaults() (factory for objects/arrays). No `||` in templates. - Never mutate a prop. Function props typed (arg: T) => void. ## Composables - One concern per file under src/composables/, named useThing.ts. - Return refs and functions, never unwrapped .value. - No side effects at import time. Cleanup with onScopeDispose. - Called at top level only, never conditionally. ## Pinia - Setup syntax: defineStore('id', () => { ... return {...} }). No options syntax. - One store per file under src/stores/, named useThingStore, id = file name. - Consume with storeToRefs(store) for state/getters; destructure actions directly. - Never `const { x } = useThingStore()` — it strips reactivity. ## v-for Keys - Every v-for needs :key with a stable unique id (item.id). Never the index. - For , :key goes on , not the child. - Generate ids at data-load time, never in the template. ## Async Components - defineAsyncComponent only for route entries, heavy v-if'd UI, or large-dep components. - Always set loadingComponent, errorComponent, delay ~200ms, timeout. - only for components with top-level await — not a generic loader. ## Tests (Vitest + @vue/test-utils) - Component.spec.ts next to component. mount() per test, never shared. - Assert rendered DOM and emitted events. Never wrapper.vm internals. - createTestingPinia() with vi.fn() actions; createMemoryHistory for router. - Snapshots only for serialized data, not component HTML. - arrange / act / assert with blank lines. Names describe behavior. You can write a hundred Vue rules and most will never fire because they cover patterns Cursor already gets right. The eight above target failure modes that compound: an Options API leak metastasizes, a destructured Pinia store ships a silent bug, an index key corrupts user state, a defineAsyncComponent blanket tanks Lighthouse, a snapshot test passes for two months while behavior breaks. Each rule pays for itself the first time Cursor would have done the wrong thing. If you want the expanded pack — these eight plus more for Nuxt 3 data fetching, Vue Router meta typing, head management, i18n keys, and the e2e patterns that pair with the Vitest rule — it is bundled in Cursor Rules Pack v2 ($27, one payment, lifetime updates). Drop it in your repo, stop fighting your AI, ship Vue you would actually merge.