skill · sumi-nuxt · v0.6.1
Vue Components
Writing Vue 3.5+ components with <script setup lang="ts"> - typed defineProps/defineEmits/defineModel/defineSlots, reactivity correctness (destructuring, shallowRef, computed over watch), provide/inject, component size and responsibility, accessible headless patterns. Use when creating, refactoring or reviewing any *.vue file or a component-level composable, or when fixing reactivity bugs, prop drilling, or oversized components.
- Reference
- sumi-nuxt
- skill
- vue-components
- source
- plugins/sumi-nuxt/skills/vue-components/SKILL.md
A good Vue component is small, typed, derives instead of synchronizes, and is accessible by default. Most bugs in Vue codebases come from copying React mental models (effects everywhere) or from losing reactivity while trying to be clever.
Core principles
- Derive, don't sync. If a value can be computed from other state, it is a
computed, never arefupdated by awatch. - One responsibility per component. Data loading, layout, and interaction widgets are separate concerns. A page orchestrates; leaf components render props and emit intent.
- Types are the API. Props, emits, models and slots are typed with TS generics. No runtime
object syntax, no
any, noPropTypegymnastics. - Semantic HTML first, ARIA second. A
<button>beats<div role="button" tabindex="0">. Accessibility rules:sumi:a11y. - Props down, events up, inject for genuinely ambient context. Not a global store for everything.
Component skeleton
<script setup lang="ts">
type Size = 'sm' | 'md' | 'lg'
const { label, size = 'md', disabled = false } = defineProps<{
label: string
size?: Size
disabled?: boolean
}>()
const emit = defineEmits<{ select: [id: string] }>()
const open = defineModel<boolean>('open', { default: false })
defineSlots<{ default(): any; icon?(props: { size: Size }): any }>()
</script>
Order inside <script setup>: types, props/emits/model/slots, injections, local state, computed,
functions, watchers (if any), lifecycle. Keep it readable top to bottom.
Props and models
- Vue 3.5 reactive props destructure is safe: destructured props stay reactive inside the template
and computed/watch getters. Passing
sizeinto a plain function call captures the current value; pass a getter() => sizeto composables instead. - Use
defineModelfor two-way bindings instead ofmodelValue+update:modelValueboilerplate. Multiple models:defineModel('open'),defineModel('query'). - Never mutate a prop. If you need a local editable copy (a form draft), copy explicitly and name it
draft, with a clear reset/commit flow. - Boolean props default to
falseand are named as adjectives (disabled,loading), notisDisabled. - Prefer a discriminated union over a bag of optional booleans:
variant: 'primary' | 'ghost'beatsprimary?: boolean; ghost?: boolean.
Reactivity rules
// BAD: destructuring a reactive() / store loses reactivity
const { count } = useCounterStore()
// GOOD
const { count } = storeToRefs(useCounterStore())
// BAD: watch to keep derived state in sync
const fullName = ref('')
watch([first, last], () => { fullName.value = `${first.value} ${last.value}` })
// GOOD
const fullName = computed(() => `${first.value} ${last.value}`)
// Large immutable data (API lists, chart series, map features): avoid deep proxying
const rows = shallowRef<Row[]>([])
rows.value = await fetchRows() // replace, don't mutate in place
refby default;reactiverarely (form objects you never destructure or reassign).shallowRef/markRawfor big lists, third-party instances (map, chart, editor), class instances.watchis for side effects tied to state changes (sync to URL, analytics, imperative APIs). UseonWatcherCleanup(3.5) or theonCleanuparg to cancel stale async work.watchEffectonly for short effects whose dependencies are obvious; prefer explicitwatchsources.computedmust be pure: no fetching, no mutations, noemit.- Template refs:
useTemplateRef<HTMLInputElement>('input')(3.5). - Stable ids for label/aria wiring:
useId()(SSR-safe), neverMath.random().
Composition: slots, provide/inject
- Use slots to let parents control rendering instead of adding props for every variation.
Scoped slots expose data:
<slot name="row" :item="item" />. - Compound components (Tabs, Accordion, Menu) share state via
provide/injectwith a typedInjectionKey, and throw a clear error when used outside the parent:
// tabs-context.ts
export interface TabsContext { active: Ref<string>; select(id: string): void }
export const TabsKey: InjectionKey<TabsContext> = Symbol('Tabs')
export function useTabsContext() {
const ctx = inject(TabsKey)
if (!ctx) throw new Error('<TabsTab> must be used inside <Tabs>')
return ctx
}
- Prop drilling more than two levels for the same value is the signal to use a slot or inject, not a Pinia store.
Size and responsibility
- Target < 150 lines per SFC (template + script). Past ~250, split. A component with more than ~8
props or several unrelated
v-ifbranches is doing too much. - Extract when a block has its own state or is reused; do not extract purely presentational 6-line fragments into separate files "for cleanliness".
- Move logic, not markup, into composables:
useCombobox(),useDisclosure(),usePagination(). - List items get a stable
:keyfrom data identity, never the index when the list can reorder.
Accessible, headless patterns
Interactive widgets (combobox, dialog, menu, tabs, listbox) are hard to get right. Prefer a proven
headless library (Reka UI, or the project's existing one) styled with Tailwind, over hand-rolled
ARIA. If you hand-roll, follow the WAI-ARIA Authoring Practices pattern exactly: roles, states,
keyboard map, focus management, and test with keyboard + screen reader. Patterns and examples:
references/patterns.md.
Non-negotiables:
- Every input has a programmatic label. Error text linked via
aria-describedby. - Dialogs trap focus, restore focus on close, close on Escape, and use
<dialog>or a library. - Icon-only buttons have an accessible name. Decorative SVGs get
aria-hidden="true". - Visible focus styles; never
outline: nonewithout a replacement. - Respect
prefers-reduced-motionin<Transition>usage.
Anti-patterns (AI slop tells)
watch+refreproducing what onecomputedwould do;useEffect-style "on mount, fetch, set state" instead ofuseFetch/useAsyncData.deep: truewatchers on large objects to "be safe".anyin props/emits, or runtimeprops: { foo: Object }with no type.- 500-line SFCs mixing fetching, formatting, layout and modal logic.
<div @click>as buttons, missing labels,v-htmlon user content.indexas:keyin sortable/filterable lists.- Wrapping every primitive in a component (
<AppText>,<AppBox>) with no behavior. - Emitting events named
change/updatewith an untyped payload instead of intent names (select,remove). - Global event buses (
mitt) for parent-child communication. nextTicksprinkled to "fix" timing bugs that are really derived-state bugs.
Done checklist
-
<script setup lang="ts">; props/emits/model/slots typed via generics; zeroany. - No derived state stored in refs; every
watchis a genuine side effect with cleanup if async. - Large/third-party data in
shallowRef/markRaw. - Component has one responsibility and fits the size budget; logic extracted to composables.
- Semantic elements, labels, keyboard support, visible focus (
sumi:a11y). - Stable keys from data identity; ids from
useId(). - Styling uses tokens/utilities per
tailwind-craft; no inline magic values.
Vue component patterns (extended)
Generic components
<script setup lang="ts" generic="T extends { id: string }">
const { items, selectedId } = defineProps<{ items: T[]; selectedId?: string }>()
const emit = defineEmits<{ select: [item: T] }>()
defineSlots<{ item(props: { item: T; selected: boolean }): any }>()
</script>
<template>
<ul role="listbox" :aria-activedescendant="selectedId ? `opt-${selectedId}` : undefined">
<li
v-for="item in items"
:id="`opt-${item.id}`"
:key="item.id"
role="option"
:aria-selected="item.id === selectedId"
@click="emit('select', item)"
>
<slot name="item" :item="item" :selected="item.id === selectedId" />
</li>
</ul>
</template>
Note: a real listbox also needs keyboard navigation (arrow keys, Home/End, typeahead) and focus management. Prefer a headless library unless you implement and test the full pattern.
Disclosure composable (logic without markup)
export function useDisclosure(initial = false) {
const open = ref(initial)
const id = useId()
const triggerProps = computed(() => ({
'aria-expanded': open.value,
'aria-controls': id,
onClick: () => { open.value = !open.value },
}))
const panelProps = computed(() => ({ id, hidden: !open.value }))
return { open, triggerProps, panelProps }
}
<button type="button" v-bind="triggerProps">Details</button>
<div v-bind="panelProps">...</div>
Async side effect with cancellation
const query = defineModel<string>('query', { default: '' })
const results = shallowRef<Result[]>([])
watch(query, async (q) => {
if (q.length < 2) { results.value = []; return }
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
results.value = await $fetch('/api/search', { query: { q }, signal: controller.signal })
})
Vue has no built-in debounce option. For debounced search use watchDebounced from VueUse; the
snippet above shows cancellation only.
Form draft pattern
const { project } = defineProps<{ project: Project }>()
const emit = defineEmits<{ save: [patch: ProjectPatch]; cancel: [] }>()
const draft = ref<ProjectPatch>({ name: project.name, budget: project.budget })
const dirty = computed(() =>
draft.value.name !== project.name || draft.value.budget !== project.budget)
watch(() => project.id, () => { // reset only when the entity changes
draft.value = { name: project.name, budget: project.budget }
})
This is one of the few legitimate prop-to-ref copies: it is explicit, named, and resets on identity change.
Accessible form field
<script setup lang="ts">
const { label, error, hint } = defineProps<{ label: string; error?: string; hint?: string }>()
const value = defineModel<string>({ required: true })
const id = useId()
const describedBy = computed(() =>
[hint && `${id}-hint`, error && `${id}-error`].filter(Boolean).join(' ') || undefined)
</script>
<template>
<div>
<label :for="id">{{ label }}</label>
<input :id="id" v-model="value" :aria-invalid="!!error" :aria-describedby="describedBy" />
<p v-if="hint" :id="`${id}-hint`">{{ hint }}</p>
<p v-if="error" :id="`${id}-error`">{{ error }}</p>
</div>
</template>
Splitting a giant component
Signs: many v-if modes, more than one fetch, a modal and a table in the same file, a methods
section longer than the template.
Steps:
- Identify state owners: which state does each region need? Draw it.
- Extract leaf presentational components first (props in, emits out).
- Extract logic into composables (
useProjectFilters,useBulkSelection). - The original becomes an orchestrator: fetches, wires composables, passes props.
- Verify no behavior change (tests or a manual keyboard + screen reader pass).
Lazy and async components
- Prefix with
Lazyin Nuxt (<LazyChartPanel v-if="showChart" />) to code-split heavy, below-fold or conditional components. - Nuxt supports lazy hydration strategies on
Lazy*components (e.g.hydrate-on-visible,hydrate-on-idle); verify availability for your Nuxt version before relying on it. defineAsyncComponentwithloadingComponent/errorComponentoutside Nuxt auto-imports.