Vue Component Patterns (Esposter)
SkillDev toolsApply when writing, typing, naming, or refactoring an individual Vue component. Esposter Vue 3 component authoring, the shared Styled/App shell primitives reused rather than re-rolled, one level of abstraction in script setup, selection read from the store, :key-remount and props-down initialisation, the wrapper + pure-child pattern for async data, a registry of heavy components holding defineAsyncComponent loaders behind Suspense, is-prefixed boolean props typed as the non-default literal, present-tense emit names, the folder path as the auto-import prefix, and defineSlots on every component that renders a slot.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Vue Component Patterns (Esposter) skill
What this skill tells your AI
The instructions your AI receives, as published by esposter/esposter in .agents/skills/vue-component-patterns/SKILL.md and read by ahel’s review.
How an individual component is written, typed and named. Assembling a page or list from components — decomposition, v-for items, action items, singleton dialogs — is the vue-page-composition skill's.
Deep dives
references/props-and-generics.md— when a prop or model type depends on an enum/discriminant key, when one component is absorbing several data variants, or when a boolean prop has a default.references/component-naming.md— when creating, renaming or moving a component file, when a directory of components gets crowded, or when a tag renders empty with no error.references/shared-shell.md— when a page needs chrome, or a new product or editor is added.references/async-components.md— when a map dispatches a heavy component, or a component awaits in setup behind av-if.references/slots.md— when a component declares a slot, forwards an optional slot into a library component, or a named slot's content has grown non-trivial.
Shared Shell / Design-System Primitives — references/shared-shell.md
Cross-product chrome is a small set of shared components in components/Styled/ (design-system) and components/App/ (app-chrome) — reuse them, never re-roll a bare v-toolbar per editor. Their design and rationale live in apps/web/content/docs/resource/shell-cohesion.md; keep that spec live in the same change when you add or alter a shell primitive.
StyledPageHeader, StyledEmptyState, StyledSkeleton, StyledSearchDialog and AppBreadcrumbs are the set; what each is for, and what a new product or editor wires up (its page's header, a launcher entry, a search palette), is that page.
Same Level of Abstraction
Every statement in <script setup> must operate at the same conceptual level. If one line calls a composable encapsulating a concept, all other lines should be at that same call-site level — not implementing sub-steps inline.
Signals abstraction levels are mixed:
- A store or composable call sits next to a manual
ref+computed+watchblock implementing the same concept (e.g. aselectedFooIdref plus a lookup computed plus a watch pruning stale selections, beside auseFooStore()that already owns selection). - A
v-if="x"guard exists only so the template body can skip absence checks (extract to a child component receiving a required prop instead). - Inline
watchcallbacks contain multi-step logic that belongs in a composable.
Fix: move the lower-level block to its owner — a store (selection state, shared reactive data — see the pinia skill) or a use* composable — then call it at the same level as everything else.
Selection State: Read the Store, Don't Thread Props
Once the selection lives in the store, children read it directly. This drops both the prop chain and the emit chain — a list item binds :active="foo.id === selectedFooId" from storeToRefs and calls selectFoo() itself, instead of the parent passing :selected-foo-id down and handling @select back up.
When a child has local mutable state initialized from a prop, don't watch the prop to reset it — use :key so the child remounts and re-initializes from the fresh prop:
<!-- ❌ watch(() => foo.fields, (newFields) => { fields.value = newFields; }) in FooEditor -->
<!-- ✅ :key remounts FooEditor on selection change -->
<FooEditor v-if="selectedFoo" :key="selectedFoo.id" :foo="selectedFoo" />
A :key names the thing being rendered, never a counter something bumps. :key="reloadCount" is a manual refresh in reactive clothing: the key says nothing about what changed, every writer has to remember to bump it, and the remount throws away scroll and focus to re-fetch data the surface could have been handed. When data changes underneath a mounted surface, the writer pushes it — a subscription handler, or a hook registry (services/shared/createHookRegistry.ts) the holding stores register into.
Prefer props-down when the parent is adjacent and already has the data — the child initializes its ref from the prop (const { fooId } = defineProps<Props>(); const selectedFooId = ref(fooId);), no watch, no store duplication. Only pass through an intermediate generic router component if the prop is truly shared by all children; if only one leaf needs it, keep the store read in that leaf and initialize its ref directly.
Async Data: Wrapper + Pure Child Pattern
When a component needs async/reactive data (e.g. a store that populates after mount), split into:
Index.vue(wrapper) — owns the data lookup + thev-ifguard; pure orchestration.Form.vue(pure child) — receives the data as a required prop and initializes local state once, synchronously; no store access for the guarded data.
This avoids async races where a ref initialized once at setup time (before the store is populated) silently overwrites real data with "".
<!-- Index.vue — wrapper owns the lookup and v-if guard -->
<template>
<FooForm v-if="foo" :foo :parent-id />
</template>
<!-- Form.vue — pure: prop is guaranteed non-undefined, so the ref init is safe -->
<script setup lang="ts">
interface Props {
foo: Foo;
parentId: string;
}
const { foo, parentId } = defineProps<Props>();
const bar = ref(foo.bar);
</script>
When to apply: any component that reads from a store/API and initializes a local editable ref from that data, where the store can be empty at component creation time.
A local editable copy of a reactive source is always VueUse useCloned, never ref + watch — the vue skill's watch decision tree owns that rule and its sync/clone options.
A Registry of Heavy Components Loads on Demand — references/async-components.md
A map dispatching a component by type puts every entry in its importer's chunk, so a registry whose entries carry heavy vendors holds defineAsyncComponent(() => import(...)) loaders, and every render site of it puts the <component :is> inside a <Suspense> whose fallback is StyledSkeleton. A registry of small components stays static, and a component that awaits in setup behind a v-if owes the same boundary.
Boolean Props — is Prefix + Default-Aware Literal Typing
isprefix. Boolean props read as a question:isDense,isInteractive,isOpen— never baredense/interactive/open, and nevercan*/should*(preferis, fall back tohas; see global naming rules). The same applies todefineModel/ emit payloads.- Type as the non-default literal, not
boolean, so passing the default is impossible: defaults-false →?: true(caller opts in with the bare attribute), defaults-true →?: falsewith a destructure default= true. Derived values, and the one exception for a genuinely two-way boolean, are inreferences/props-and-generics.md.
Emits — Present-Tense Event Names
Emit names are present-tense verbs: delete, update, create, save, submit — never past tense (deleted, updated, copied). The event names the action the parent should handle, not a completed fact; past-tense names also drift from Vue/DOM convention (click, submit, change).
For state-sync emits, use the update:x form where x is the state name ("update:copied": [boolean]) — the verb stays present tense; the state name may be any shape.
Component Folder Naming — references/component-naming.md
A component that gains a folder moves into it as Index.vue, never beside the folder; the folder path is the prefix and is never repeated in the filename, since Nuxt builds the auto-import name from both.
File Length
Line-count target and exceptions — see the file-organization skill. Component-specific extractions when a .vue runs long: pull toolbar/header buttons into a slot component (e.g. TopSlot.vue), row/column action menus into ActionSlot.vue, and grouped controls into their own focused component.
Slots
Every component that renders a <slot> declares defineSlots — typed slot contracts, same as props (defineSlots<{ default: () => VNode }>(); ?: when the consumer may omit it). Forwarding an optional slot into a library component that falls back to a prop needs #default + v-if on the same template, and extracting a non-trivial slot's content into its own component follows a naming convention — both in references/slots.md.
Signals
- GitHub stars
- 23
- Forks
- 3
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
vue-component-patterns- Source
- github.com/esposter/esposter