Vue Component Patterns
A Vue component is a contract: typed props in, declared events out, everything else private. Most tangled Vue codebases broke exactly that contract.
When to use
- Creating or reviewing Vue 3 components (
<script setup>)
- "Where should this state live?" — local, parent, composable, or Pinia
- Extracting logic shared between components
- Reactivity bugs (stale values, lost reactivity, surprise re-renders)
Principles
- Props down, events up — no exceptions. Mutating a prop, reaching into
$parent, or passing callbacks-as-props breaks traceability. defineModel for two-way intent.
- State lives at the lowest level that all readers share. Local until two components need it; lift to the common parent; Pinia only for genuinely app-wide state (auth, cart) — not as a prop-drilling shortcut.
- Composables own logic, components own presentation. When a component grows watchers + fetches + transforms, the logic wants to be a
useX() composable with its own test.
- Types are the documentation. Typed props, typed emits, typed composable returns. An
any prop is an undocumented API.
Process
1. Define the contract first
<script setup lang="ts">
const props = defineProps<{
order: Order
editable?: boolean
}>()
const emit = defineEmits<{
save: [order: Order]
cancel: []
}>()
</script>
- Props: data the component renders or branches on. Don't pass a whole object when it uses two fields — but don't explode into 12 scalar props either; group cohesive data
- Events: named after what happened (
save, item-selected), not what the parent should do (refetchList)
2. Place the state
| State |
Lives in |
| UI-only (open/closed, hover, input draft) |
Component-local ref |
| Shared by siblings |
Common parent, passed down |
| Form state across a multi-step flow |
Parent or a scoped composable |
| Server data |
Data-fetch layer (composable / query lib), cached — not copied into Pinia |
| True app-globals (auth user, locale, cart) |
Pinia store |
3. Extract composables (when, not how)
Extract useX() when: the same logic appears twice, or a component mixes 2+ concerns (fetching + debouncing + formatting).
- Composable returns refs + functions, no DOM or component references
- One concern per composable:
useOrderSearch(), not useOrderStuff()
- Side-effectful composables clean up after themselves (
onUnmounted, abort controllers)
4. Respect reactivity rules
- Don't destructure
props or a reactive() (loses reactivity) — toRefs/computed or access via the object
computed for derived data; a watch that just recomputes a value should be a computed
watch is for side effects (fetch on param change, sync to localStorage); keep them few — watcher webs are where reactivity bugs breed
- Lists: stable
:key (id, never index when reordering/filtering)
5. Keep renders cheap
- Heavy lists: pagination/virtual scrolling before memo-tricks
- Expensive pure subtrees:
v-memo / split into a child so its re-render scope shrinks
shallowRef for large immutable structures (big API payloads) you replace wholesale
- Defer below-the-fold weight:
defineAsyncComponent
6. Review pass
# Smells worth grepping for
grep -rn "props\.\w* =" src/ # prop mutation
grep -rn '\$parent\|getCurrentInstance' src/
grep -rn ":key=\"index\"\|:key=\"i\"" src/
Plus: components > ~200 lines of script (extract composable), stores imported by everything (Pinia as global junk drawer), emits not declared/typed.
Output format
## Vue review: <component/feature>
### Contract
Props: … | Emits: … | v-model: … — typed: ✅/❌
### State placement
| State | Now | Should be |
|-------|-----|-----------|
| … | Pinia | parent-local |
### Extractions
- `useX()` from <component> — owns: …
### Findings (ranked)
1. <file:line> — <issue> → <fix>
Anti-patterns
- ❌ Mutating props, or "syncing" a prop into a local ref and editing the copy
- ❌ Events named as commands to the parent (
refetch) instead of facts (saved)
- ❌ Pinia as a prop-drilling workaround — every component coupled to global state
- ❌ Server data copied into a store, then drifting from the server
- ❌ Watcher chains where A watches B watches C — replace with computed
- ❌
:key="index" on a filterable/sortable list
- ❌ One 600-line component with fetching, transforming, and three modals inside
Source: kwhorne/elyra-skills — distributed by TomeVault.
1---2name: vue-component-patterns-23description: Design Vue 3 components with Composition API discipline - props/emits contracts, composables for shared logic, state placement (local vs Pinia), and render performance. Use when creating or reviewing Vue components, deciding where state lives, extracting reusable logic, debugging reactivity issues, or when a Vue app's components have grown tangled. Use when this capability is needed.4---56# Vue Component Patterns78A Vue component is a **contract**: typed props in, declared events out, everything else private. Most tangled Vue codebases broke exactly that contract.910## When to use1112- Creating or reviewing Vue 3 components (`<script setup>`)13- "Where should this state live?" — local, parent, composable, or Pinia14- Extracting logic shared between components15- Reactivity bugs (stale values, lost reactivity, surprise re-renders)1617## Principles1819- **Props down, events up — no exceptions.** Mutating a prop, reaching into `$parent`, or passing callbacks-as-props breaks traceability. `defineModel` for two-way intent.20- **State lives at the lowest level that all readers share.** Local until two components need it; lift to the common parent; Pinia only for genuinely app-wide state (auth, cart) — not as a prop-drilling shortcut.21- **Composables own logic, components own presentation.** When a component grows watchers + fetches + transforms, the logic wants to be a `useX()` composable with its own test.22- **Types are the documentation.** Typed props, typed emits, typed composable returns. An `any` prop is an undocumented API.2324## Process2526### 1. Define the contract first2728```vue29<script setup lang="ts">30const props = defineProps<{31 order: Order32 editable?: boolean33}>()3435const emit = defineEmits<{36 save: [order: Order]37 cancel: []38}>()39</script>40```4142- Props: data the component *renders or branches on*. Don't pass a whole object when it uses two fields — but don't explode into 12 scalar props either; group cohesive data43- Events: named after *what happened* (`save`, `item-selected`), not what the parent should do (`refetchList`)4445### 2. Place the state4647| State | Lives in |48|---|---|49| UI-only (open/closed, hover, input draft) | Component-local `ref` |50| Shared by siblings | Common parent, passed down |51| Form state across a multi-step flow | Parent or a scoped composable |52| Server data | Data-fetch layer (composable / query lib), cached — not copied into Pinia |53| True app-globals (auth user, locale, cart) | Pinia store |5455### 3. Extract composables (when, not how)5657Extract `useX()` when: the same logic appears twice, or a component mixes 2+ concerns (fetching + debouncing + formatting).5859- Composable returns **refs + functions**, no DOM or component references60- One concern per composable: `useOrderSearch()`, not `useOrderStuff()`61- Side-effectful composables clean up after themselves (`onUnmounted`, abort controllers)6263### 4. Respect reactivity rules6465- Don't destructure `props` or a `reactive()` (loses reactivity) — `toRefs`/`computed` or access via the object66- `computed` for derived data; a `watch` that just recomputes a value should be a `computed`67- `watch` is for *side effects* (fetch on param change, sync to localStorage); keep them few — watcher webs are where reactivity bugs breed68- Lists: stable `:key` (id, never index when reordering/filtering)6970### 5. Keep renders cheap7172- Heavy lists: pagination/virtual scrolling before memo-tricks73- Expensive pure subtrees: `v-memo` / split into a child so its re-render scope shrinks74- `shallowRef` for large immutable structures (big API payloads) you replace wholesale75- Defer below-the-fold weight: `defineAsyncComponent`7677### 6. Review pass7879```bash80# Smells worth grepping for81grep -rn "props\.\w* =" src/ # prop mutation82grep -rn '\$parent\|getCurrentInstance' src/83grep -rn ":key=\"index\"\|:key=\"i\"" src/84```8586Plus: components > ~200 lines of script (extract composable), stores imported by everything (Pinia as global junk drawer), emits not declared/typed.8788## Output format8990```markdown91## Vue review: <component/feature>9293### Contract94Props: … | Emits: … | v-model: … — typed: ✅/❌9596### State placement97| State | Now | Should be |98|-------|-----|-----------|99| … | Pinia | parent-local |100101### Extractions102- `useX()` from <component> — owns: …103104### Findings (ranked)1051. <file:line> — <issue> → <fix>106```107108## Anti-patterns109110- ❌ Mutating props, or "syncing" a prop into a local ref and editing the copy111- ❌ Events named as commands to the parent (`refetch`) instead of facts (`saved`)112- ❌ Pinia as a prop-drilling workaround — every component coupled to global state113- ❌ Server data copied into a store, then drifting from the server114- ❌ Watcher chains where A watches B watches C — replace with computed115- ❌ `:key="index"` on a filterable/sortable list116- ❌ One 600-line component with fetching, transforming, and three modals inside117118---119> Source: [kwhorne/elyra-skills](https://github.com/kwhorne/elyra-skills) — distributed by [TomeVault](https://tomevault.io).120<!-- tomevault:4.0:skill_md:2026-06-30 -->