mdk-skills 2.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/.install.log +4 -0
- package/.claude/settings.json +64 -0
- package/.claude/settings.local.json +7 -0
- package/.claude/skills/agentation/.meta.json +6 -0
- package/.claude/skills/agentation/SKILL.md +107 -0
- package/.claude/skills/fe-biz-patterns/.meta.json +6 -0
- package/.claude/skills/fe-biz-patterns/SKILL.md +26 -0
- package/.claude/skills/fe-biz-patterns/references/infinite-scroll.md +292 -0
- package/.claude/skills/fe-biz-patterns/references/pinia-store.md +174 -0
- package/.claude/skills/fe-biz-patterns/references/service-layer.md +198 -0
- package/.claude/skills/fe-biz-patterns/references/tab-anchor.md +1125 -0
- package/.claude/skills/fe-biz-patterns/references/use-loading.md +114 -0
- package/.claude/skills/frontend-code-review/.meta.json +6 -0
- package/.claude/skills/frontend-code-review/SKILL.md +167 -0
- package/.claude/skills/frontend-code-review/references/checklist.md +298 -0
- package/.claude/skills/frontend-design/.meta.json +6 -0
- package/.claude/skills/frontend-design/LICENSE.txt +177 -0
- package/.claude/skills/frontend-design/SKILL.md +42 -0
- package/.claude/skills/moai-framework-electron/.meta.json +6 -0
- package/.claude/skills/moai-framework-electron/SKILL.md +328 -0
- package/.claude/skills/skill-creator/.meta.json +6 -0
- package/.claude/skills/skill-creator/SKILL.md +356 -0
- package/.claude/skills/skill-creator/references/output-patterns.md +82 -0
- package/.claude/skills/skill-creator/references/workflows.md +28 -0
- package/.claude/skills/skill-creator/scripts/init_skill.py +303 -0
- package/.claude/skills/skill-creator/scripts/package_skill.py +110 -0
- package/.claude/skills/skill-creator/scripts/quick_validate.py +95 -0
- package/.claude/skills/ui-ux-pro-max/.meta.json +6 -0
- package/.claude/skills/ui-ux-pro-max/SKILL.md +228 -0
- package/.claude/skills/ui-ux-pro-max/data/charts.csv +26 -0
- package/.claude/skills/ui-ux-pro-max/data/colors.csv +97 -0
- package/.claude/skills/ui-ux-pro-max/data/landing.csv +31 -0
- package/.claude/skills/ui-ux-pro-max/data/products.csv +97 -0
- package/.claude/skills/ui-ux-pro-max/data/prompts.csv +24 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/flutter.csv +53 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/html-tailwind.csv +56 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/nextjs.csv +53 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/nuxt-ui.csv +51 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/nuxtjs.csv +59 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/react-native.csv +52 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/react.csv +54 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/svelte.csv +54 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/swiftui.csv +51 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/vue.csv +50 -0
- package/.claude/skills/ui-ux-pro-max/data/styles.csv +59 -0
- package/.claude/skills/ui-ux-pro-max/data/typography.csv +58 -0
- package/.claude/skills/ui-ux-pro-max/data/ux-guidelines.csv +100 -0
- package/.claude/skills/ui-ux-pro-max/scripts/core.py +238 -0
- package/.claude/skills/ui-ux-pro-max/scripts/search.py +61 -0
- package/.claude/skills/vue/.meta.json +6 -0
- package/.claude/skills/vue/SKILL.md +103 -0
- package/.claude/skills/vue/references/components.md +323 -0
- package/.claude/skills/vue/references/composables.md +358 -0
- package/.claude/skills/vue/references/directives.md +225 -0
- package/.claude/skills/vue/references/gotchas.md +438 -0
- package/.claude/skills/vue/references/provide-inject.md +174 -0
- package/.claude/skills/vue/references/reactivity.md +289 -0
- package/.claude/skills/vue/references/router.md +181 -0
- package/.claude/skills/vue/references/testing.md +294 -0
- package/.claude/skills/vue/references/typescript.md +172 -0
- package/.claude/skills/vue/references/utils-client.md +156 -0
- package/CLAUDE.md +131 -0
- package/package.json +23 -0
- package/scripts/cli.js +260 -0
- package/scripts/copy-skills.js +86 -0
- package/scripts/core.js +256 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
# Vue Components
|
|
2
|
+
|
|
3
|
+
Patterns for Vue 3 components using Composition API with `<script setup>`.
|
|
4
|
+
|
|
5
|
+
## Quick Reference
|
|
6
|
+
|
|
7
|
+
| Pattern | Syntax |
|
|
8
|
+
| --------------------- | --------------------------------------------------------------- |
|
|
9
|
+
| Props (destructured) | `const { name = 'default' } = defineProps<{ name?: string }>()` |
|
|
10
|
+
| Props (template-only) | `defineProps<{ name: string }>()` |
|
|
11
|
+
| Emits | `const emit = defineEmits<{ click: [id: number] }>()` |
|
|
12
|
+
| Two-way binding | `const model = defineModel<string>()` |
|
|
13
|
+
| Slots shorthand | `<template #header>` not `<template v-slot:header>` |
|
|
14
|
+
|
|
15
|
+
## Naming
|
|
16
|
+
|
|
17
|
+
**Files:** PascalCase (`UserProfile.vue`) OR kebab-case (`user-profile.vue`) - be consistent
|
|
18
|
+
|
|
19
|
+
**Component names in code:** Always PascalCase
|
|
20
|
+
|
|
21
|
+
**Composition:** General → Specific: `SearchButtonClear.vue` not `ClearSearchButton.vue`
|
|
22
|
+
|
|
23
|
+
## Props
|
|
24
|
+
|
|
25
|
+
**Destructure with defaults (Vue 3.5+)** when used in script or need defaults:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
const { count = 0, message = 'Hello' } = defineProps<{
|
|
29
|
+
count?: number
|
|
30
|
+
message?: string
|
|
31
|
+
required: boolean
|
|
32
|
+
}>()
|
|
33
|
+
|
|
34
|
+
// Use directly - maintains reactivity
|
|
35
|
+
console.log(count + 1)
|
|
36
|
+
|
|
37
|
+
// ⚠️ When passing to watchers/functions, wrap in getter:
|
|
38
|
+
watch(() => count, (newVal) => { ... }) // ✅ Correct
|
|
39
|
+
watch(count, (newVal) => { ... }) // ❌ Won't work
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Non-destructured** only if props ONLY used in template:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
defineProps<{ count: number }>()
|
|
46
|
+
// Template: {{ count }}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**Same-name shorthand (Vue 3.4+):** `:count` instead of `:count="count"`
|
|
50
|
+
|
|
51
|
+
```vue
|
|
52
|
+
<MyComponent :count :user :items />
|
|
53
|
+
<!-- Same as: :count="count" :user="user" :items="items" -->
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
[Reactive destructuring docs](https://vuejs.org/guide/components/props#reactive-props-destructure)
|
|
57
|
+
|
|
58
|
+
## Emits
|
|
59
|
+
|
|
60
|
+
Type-safe event definitions:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
const emit = defineEmits<{
|
|
64
|
+
update: [id: number, value: string] // multiple args
|
|
65
|
+
close: [] // no args
|
|
66
|
+
}>()
|
|
67
|
+
|
|
68
|
+
// Usage
|
|
69
|
+
emit('update', 123, 'new value')
|
|
70
|
+
emit('close')
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Template syntax:** kebab-case (`@update-item`) vs camelCase in script (`updateItem`)
|
|
74
|
+
|
|
75
|
+
## Slots
|
|
76
|
+
|
|
77
|
+
**Always use shorthand:** `<template #header>` not `<template v-slot:header>`
|
|
78
|
+
|
|
79
|
+
**Always explicit `<template>` tags** for all slots
|
|
80
|
+
|
|
81
|
+
```vue
|
|
82
|
+
<template>
|
|
83
|
+
<Card>
|
|
84
|
+
<template #header>
|
|
85
|
+
<h2>Title</h2>
|
|
86
|
+
</template>
|
|
87
|
+
<template #default>
|
|
88
|
+
Content
|
|
89
|
+
</template>
|
|
90
|
+
</Card>
|
|
91
|
+
</template>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## defineModel() - Two-Way Binding
|
|
95
|
+
|
|
96
|
+
Replaces manual `modelValue` prop + `update:modelValue` emit.
|
|
97
|
+
|
|
98
|
+
### Basic
|
|
99
|
+
|
|
100
|
+
```vue
|
|
101
|
+
<script setup lang="ts">
|
|
102
|
+
const title = defineModel<string>()
|
|
103
|
+
</script>
|
|
104
|
+
|
|
105
|
+
<template>
|
|
106
|
+
<input v-model="title">
|
|
107
|
+
</template>
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### With Options
|
|
111
|
+
|
|
112
|
+
```vue
|
|
113
|
+
<script setup lang="ts">
|
|
114
|
+
const [title, modifiers] = defineModel<string>({
|
|
115
|
+
default: 'default value',
|
|
116
|
+
required: true,
|
|
117
|
+
get: (value) => value.trim(),
|
|
118
|
+
set: (value) => {
|
|
119
|
+
if (modifiers.capitalize) {
|
|
120
|
+
return value.charAt(0).toUpperCase() + value.slice(1)
|
|
121
|
+
}
|
|
122
|
+
return value
|
|
123
|
+
},
|
|
124
|
+
})
|
|
125
|
+
</script>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**⚠️ Warning:** When using `default` without parent providing a value, parent and child can de-sync (parent `undefined`, child has default). Always provide matching defaults in parent or make prop required.
|
|
129
|
+
|
|
130
|
+
**Prevent double-emit with `required: true`:**
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
// ❌ Without required - emits twice (undefined then value)
|
|
134
|
+
const model = defineModel<Item>()
|
|
135
|
+
|
|
136
|
+
// ✅ With required - single emit
|
|
137
|
+
const model = defineModel<Item>({ required: true })
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Use `required: true` when the model should always have a value to avoid the double-emit issue during initialization.
|
|
141
|
+
|
|
142
|
+
### Multiple Models
|
|
143
|
+
|
|
144
|
+
Default assumes `modelValue` prop. For multiple bindings, use explicit names:
|
|
145
|
+
|
|
146
|
+
```vue
|
|
147
|
+
<script setup lang="ts">
|
|
148
|
+
const firstName = defineModel<string>('firstName')
|
|
149
|
+
const age = defineModel<number>('age')
|
|
150
|
+
</script>
|
|
151
|
+
|
|
152
|
+
<!-- Usage -->
|
|
153
|
+
<UserForm v-model:first-name="user.firstName" v-model:age="user.age" />
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
[v-model modifiers docs](https://vuejs.org/guide/components/v-model#handling-v-model-modifiers)
|
|
157
|
+
|
|
158
|
+
## Reusable Templates
|
|
159
|
+
|
|
160
|
+
For typed, scoped template snippets within a component:
|
|
161
|
+
|
|
162
|
+
```vue
|
|
163
|
+
<script setup lang="ts">
|
|
164
|
+
import { createReusableTemplate } from '@vueuse/core'
|
|
165
|
+
|
|
166
|
+
const [DefineItem, UseItem] = createReusableTemplate<{
|
|
167
|
+
item: SearchItem
|
|
168
|
+
icon: string
|
|
169
|
+
color?: 'red' | 'green' | 'blue'
|
|
170
|
+
}>()
|
|
171
|
+
</script>
|
|
172
|
+
|
|
173
|
+
<template>
|
|
174
|
+
<DefineItem v-slot="{ item, icon, color }">
|
|
175
|
+
<div :class="color">
|
|
176
|
+
<Icon :name="icon" />
|
|
177
|
+
{{ item.name }}
|
|
178
|
+
</div>
|
|
179
|
+
</DefineItem>
|
|
180
|
+
|
|
181
|
+
<!-- Reuse multiple times -->
|
|
182
|
+
<UseItem v-for="item in items" :key="item.id" :item :icon="getIcon(item)" />
|
|
183
|
+
</template>
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Template Refs (Vue 3.5+)
|
|
187
|
+
|
|
188
|
+
Use `useTemplateRef()` for type-safe template references with IDE support:
|
|
189
|
+
|
|
190
|
+
```vue
|
|
191
|
+
<script setup lang="ts">
|
|
192
|
+
import { useTemplateRef, onMounted } from 'vue'
|
|
193
|
+
|
|
194
|
+
const input = useTemplateRef<HTMLInputElement>('my-input')
|
|
195
|
+
|
|
196
|
+
onMounted(() => {
|
|
197
|
+
input.value?.focus()
|
|
198
|
+
})
|
|
199
|
+
</script>
|
|
200
|
+
|
|
201
|
+
<template>
|
|
202
|
+
<input ref="my-input">
|
|
203
|
+
</template>
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
**Benefits over `ref()`:**
|
|
207
|
+
|
|
208
|
+
- Type-safe with generics
|
|
209
|
+
- Better IDE autocomplete and refactoring
|
|
210
|
+
- Explicit ref name as string literal
|
|
211
|
+
|
|
212
|
+
**Dynamic refs:**
|
|
213
|
+
|
|
214
|
+
```vue
|
|
215
|
+
<script setup lang="ts">
|
|
216
|
+
const items = ref(['a', 'b', 'c'])
|
|
217
|
+
const itemRefs = useTemplateRef<HTMLElement>('item')
|
|
218
|
+
|
|
219
|
+
// Access refs after mount
|
|
220
|
+
onMounted(() => {
|
|
221
|
+
console.log(itemRefs.value) // Array of elements
|
|
222
|
+
})
|
|
223
|
+
</script>
|
|
224
|
+
|
|
225
|
+
<template>
|
|
226
|
+
<div v-for="item in items" :key="item" ref="item">
|
|
227
|
+
{{ item }}
|
|
228
|
+
</div>
|
|
229
|
+
</template>
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
**Component refs with generics:**
|
|
233
|
+
|
|
234
|
+
For generic components, use `ComponentExposed` from `vue-component-type-helpers`:
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
import type { ComponentExposed } from 'vue-component-type-helpers'
|
|
238
|
+
import MyGenericComponent from './MyGenericComponent.vue'
|
|
239
|
+
|
|
240
|
+
// Get exposed methods/properties with correct generic types
|
|
241
|
+
const compRef = useTemplateRef<ComponentExposed<typeof MyGenericComponent>>('comp')
|
|
242
|
+
|
|
243
|
+
onMounted(() => {
|
|
244
|
+
compRef.value?.someExposedMethod() // Typed!
|
|
245
|
+
})
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Install: `pnpm add -D vue-component-type-helpers`
|
|
249
|
+
|
|
250
|
+
## SSR Hydration (Vue 3.5+)
|
|
251
|
+
|
|
252
|
+
**Suppress hydration mismatches** for values that differ between server/client:
|
|
253
|
+
|
|
254
|
+
```vue
|
|
255
|
+
<template>
|
|
256
|
+
<!-- Client-side only values -->
|
|
257
|
+
<span data-allow-mismatch>{{ new Date().toLocaleString() }}</span>
|
|
258
|
+
|
|
259
|
+
<!-- Specific mismatch types -->
|
|
260
|
+
<span data-allow-mismatch="text">{{ timestamp }}</span>
|
|
261
|
+
<span data-allow-mismatch="children">
|
|
262
|
+
<ClientOnly>...</ClientOnly>
|
|
263
|
+
</span>
|
|
264
|
+
<span data-allow-mismatch="style">...</span>
|
|
265
|
+
<span data-allow-mismatch="class">...</span>
|
|
266
|
+
<span data-allow-mismatch="attribute">...</span>
|
|
267
|
+
</template>
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
**Generate SSR-stable IDs:**
|
|
271
|
+
|
|
272
|
+
```vue
|
|
273
|
+
<script setup lang="ts">
|
|
274
|
+
import { useId } from 'vue'
|
|
275
|
+
|
|
276
|
+
const id = useId() // Stable across server/client renders
|
|
277
|
+
</script>
|
|
278
|
+
|
|
279
|
+
<template>
|
|
280
|
+
<label :for="id">Name</label>
|
|
281
|
+
<input :id="id">
|
|
282
|
+
</template>
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## Deferred Teleport (Vue 3.5+)
|
|
286
|
+
|
|
287
|
+
Teleport to elements rendered later in the same cycle:
|
|
288
|
+
|
|
289
|
+
```vue
|
|
290
|
+
<template>
|
|
291
|
+
<!-- This renders first -->
|
|
292
|
+
<Teleport defer to="#late-div">
|
|
293
|
+
<span>Deferred content</span>
|
|
294
|
+
</Teleport>
|
|
295
|
+
|
|
296
|
+
<!-- This renders after, but Teleport waits -->
|
|
297
|
+
<div id="late-div"></div>
|
|
298
|
+
</template>
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Without `defer`, teleport to `#late-div` would fail since it doesn't exist yet.
|
|
302
|
+
|
|
303
|
+
## Common Mistakes
|
|
304
|
+
|
|
305
|
+
**Using `const props =` with destructured values:**
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
// ❌ Wrong
|
|
309
|
+
const props = defineProps<{ count: number }>()
|
|
310
|
+
const { count } = props // Loses reactivity
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
**Forgetting TypeScript types:**
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
// ❌ Wrong
|
|
317
|
+
const emit = defineEmits(['update'])
|
|
318
|
+
|
|
319
|
+
// ✅ Correct
|
|
320
|
+
const emit = defineEmits<{ update: [id: number] }>()
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
**Components >300 lines:** Split into smaller components or extract logic to composables
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
# Vue Composables
|
|
2
|
+
|
|
3
|
+
Reusable functions encapsulating stateful logic using Composition API.
|
|
4
|
+
|
|
5
|
+
## Core Rules
|
|
6
|
+
|
|
7
|
+
1. **VueUse first** - check [vueuse.org](https://vueuse.org) before writing custom
|
|
8
|
+
2. **No async composables** - lose lifecycle context when awaited in other composables
|
|
9
|
+
3. **Top-level only** - never call in event handlers, conditionals, or loops
|
|
10
|
+
4. **readonly() exports** - protect internal state from external mutation
|
|
11
|
+
5. **useState() for SSR** - use Nuxt's `useState()` not global refs
|
|
12
|
+
|
|
13
|
+
## Quick Reference
|
|
14
|
+
|
|
15
|
+
| Pattern | Example |
|
|
16
|
+
| --------- | ------------------------------------------------ |
|
|
17
|
+
| Naming | `useAuth`, `useCounter`, `useDebounce` |
|
|
18
|
+
| State | `const count = ref(0)` |
|
|
19
|
+
| Computed | `const double = computed(() => count.value * 2)` |
|
|
20
|
+
| Lifecycle | `onMounted(() => ...)`, `onUnmounted(() => ...)` |
|
|
21
|
+
| Return | `return { count, increment }` |
|
|
22
|
+
|
|
23
|
+
## Structure
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
// composables/useCounter.ts
|
|
27
|
+
import { readonly, ref } from 'vue'
|
|
28
|
+
|
|
29
|
+
export function useCounter(initialValue = 0) {
|
|
30
|
+
const count = ref(initialValue)
|
|
31
|
+
|
|
32
|
+
function increment() { count.value++ }
|
|
33
|
+
function decrement() { count.value-- }
|
|
34
|
+
function reset() { count.value = initialValue }
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
count: readonly(count), // readonly if shouldn't be mutated
|
|
38
|
+
increment,
|
|
39
|
+
decrement,
|
|
40
|
+
reset,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Naming
|
|
46
|
+
|
|
47
|
+
**Always prefix with `use`:** `useAuth`, `useLocalStorage`, `useDebounce`
|
|
48
|
+
|
|
49
|
+
**File = function:** `useAuth.ts` exports `useAuth`
|
|
50
|
+
|
|
51
|
+
## Best Practices
|
|
52
|
+
|
|
53
|
+
**Do:**
|
|
54
|
+
|
|
55
|
+
- Return object with named properties (destructuring-friendly)
|
|
56
|
+
- Accept options object for configuration
|
|
57
|
+
- Use `readonly()` for state that shouldn't mutate
|
|
58
|
+
- Handle cleanup (`onUnmounted`, `onScopeDispose`)
|
|
59
|
+
- Add JSDoc for complex functions
|
|
60
|
+
|
|
61
|
+
## Lifecycle
|
|
62
|
+
|
|
63
|
+
Hooks execute in component context:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
export function useEventListener(target: EventTarget, event: string, handler: Function) {
|
|
67
|
+
onMounted(() => target.addEventListener(event, handler))
|
|
68
|
+
onUnmounted(() => target.removeEventListener(event, handler))
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**Watcher cleanup (Vue 3.5+):**
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { watch, onWatcherCleanup } from 'vue'
|
|
76
|
+
|
|
77
|
+
export function usePolling(url: Ref<string>) {
|
|
78
|
+
watch(url, (newUrl) => {
|
|
79
|
+
const interval = setInterval(() => {
|
|
80
|
+
fetch(newUrl).then(/* ... */)
|
|
81
|
+
}, 1000)
|
|
82
|
+
|
|
83
|
+
// Cleanup when watcher re-runs or stops
|
|
84
|
+
onWatcherCleanup(() => {
|
|
85
|
+
clearInterval(interval)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Benefits of `onWatcherCleanup()`:**
|
|
92
|
+
|
|
93
|
+
- Cleaner than returning cleanup functions
|
|
94
|
+
- Works with async watchers
|
|
95
|
+
- Can be called multiple times in same watcher
|
|
96
|
+
|
|
97
|
+
## Async Pattern
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
export function useAsyncData<T>(fetcher: () => Promise<T>) {
|
|
101
|
+
const data = ref<T | null>(null)
|
|
102
|
+
const error = ref<Error | null>(null)
|
|
103
|
+
const loading = ref(false)
|
|
104
|
+
|
|
105
|
+
async function execute() {
|
|
106
|
+
loading.value = true
|
|
107
|
+
error.value = null
|
|
108
|
+
try {
|
|
109
|
+
data.value = await fetcher()
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
error.value = e as Error
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
loading.value = false
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
execute()
|
|
120
|
+
return { data, error, loading, refetch: execute }
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**Data fetching:** Prefer Pinia Colada queries over custom composables.
|
|
125
|
+
|
|
126
|
+
## VueUse
|
|
127
|
+
|
|
128
|
+
> For VueUse composable reference, use the `vueuse` skill.
|
|
129
|
+
|
|
130
|
+
Check VueUse before writing custom composables - most patterns already implemented.
|
|
131
|
+
|
|
132
|
+
> **For Nuxt-specific composables** (useFetch, useRequestURL): see `nuxt` skill nuxt-composables.md
|
|
133
|
+
|
|
134
|
+
## Advanced Patterns
|
|
135
|
+
|
|
136
|
+
### Singleton Composable
|
|
137
|
+
|
|
138
|
+
Share state across all components using the same composable:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import { createSharedComposable } from '@vueuse/core'
|
|
142
|
+
|
|
143
|
+
function useMapControlsBase() {
|
|
144
|
+
const mapInstance = ref<Map | null>(null)
|
|
145
|
+
const flyTo = (coords: [number, number]) => mapInstance.value?.flyTo(coords)
|
|
146
|
+
return { mapInstance, flyTo }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export const useMapControls = createSharedComposable(useMapControlsBase)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Cancellable Fetch with AbortController
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
export function useSearch() {
|
|
156
|
+
let abortController: AbortController | null = null
|
|
157
|
+
|
|
158
|
+
watch(query, async (newQuery) => {
|
|
159
|
+
abortController?.abort()
|
|
160
|
+
abortController = new AbortController()
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const data = await $fetch('/api/search', {
|
|
164
|
+
query: { q: newQuery },
|
|
165
|
+
signal: abortController.signal,
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
if (e.name !== 'AbortError')
|
|
170
|
+
throw e
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Step-Based State Machine
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
export function useSendFlow() {
|
|
180
|
+
const step = ref<'input' | 'confirm' | 'success'>('input')
|
|
181
|
+
const amount = ref('')
|
|
182
|
+
|
|
183
|
+
const next = () => {
|
|
184
|
+
if (step.value === 'input')
|
|
185
|
+
step.value = 'confirm'
|
|
186
|
+
else if (step.value === 'confirm')
|
|
187
|
+
step.value = 'success'
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { step, amount, next }
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Client-Only Guards
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
export function useUserLocation() {
|
|
198
|
+
const location = ref<GeolocationPosition | null>(null)
|
|
199
|
+
|
|
200
|
+
if (import.meta.client) {
|
|
201
|
+
navigator.geolocation.getCurrentPosition(pos => location.value = pos)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { location }
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Custom Element Composables (Vue 3.5+)
|
|
209
|
+
|
|
210
|
+
For custom element components, use built-in helpers:
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
import { useHost, useShadowRoot } from 'vue'
|
|
214
|
+
|
|
215
|
+
export function useCustomElement() {
|
|
216
|
+
const host = useHost() // Host element reference
|
|
217
|
+
const shadowRoot = useShadowRoot() // Shadow DOM root
|
|
218
|
+
|
|
219
|
+
onMounted(() => {
|
|
220
|
+
console.log('Host:', host)
|
|
221
|
+
console.log('Shadow:', shadowRoot)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
return { host, shadowRoot }
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
**Available in:**
|
|
229
|
+
|
|
230
|
+
- Components using `<script setup>` in custom elements
|
|
231
|
+
- Access via `this.$host` in Options API
|
|
232
|
+
|
|
233
|
+
### Auto-Save with Debounce
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
export function useAutoSave(content: Ref<string>) {
|
|
237
|
+
const hasChanges = ref(false)
|
|
238
|
+
|
|
239
|
+
const save = useDebounceFn(async () => {
|
|
240
|
+
if (!hasChanges.value)
|
|
241
|
+
return
|
|
242
|
+
await $fetch('/api/save', { method: 'POST', body: { content: content.value } })
|
|
243
|
+
hasChanges.value = false
|
|
244
|
+
}, 1000)
|
|
245
|
+
|
|
246
|
+
watch(content, () => {
|
|
247
|
+
hasChanges.value = true
|
|
248
|
+
save()
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
return { hasChanges }
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Tagged Logger
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
import { consola } from 'consola'
|
|
259
|
+
|
|
260
|
+
export function useSearch() {
|
|
261
|
+
const logger = consola.withTag('search')
|
|
262
|
+
|
|
263
|
+
watch(query, (q) => {
|
|
264
|
+
logger.info('Query changed:', q)
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## Reactivity Gotchas
|
|
270
|
+
|
|
271
|
+
### Ref Unwrapping in Reactive
|
|
272
|
+
|
|
273
|
+
Refs auto-unwrap in `reactive()` objects but **NOT** in arrays, Maps, or Sets:
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
// ✅ Object - auto unwraps
|
|
277
|
+
const state = reactive({ count: ref(0) })
|
|
278
|
+
state.count++ // No .value needed
|
|
279
|
+
|
|
280
|
+
// ❌ Array - NO unwrapping
|
|
281
|
+
const arr = reactive([ref(1)])
|
|
282
|
+
arr[0].value // Need .value!
|
|
283
|
+
|
|
284
|
+
// ❌ Map/Set - NO unwrapping
|
|
285
|
+
const map = reactive(new Map([['key', ref(1)]]))
|
|
286
|
+
map.get('key').value // Need .value!
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### watchEffect Conditional Tracking
|
|
290
|
+
|
|
291
|
+
Dependencies inside conditional branches are **not tracked** when condition is false:
|
|
292
|
+
|
|
293
|
+
```ts
|
|
294
|
+
// ❌ Wrong - dep not tracked when condition false
|
|
295
|
+
watchEffect(() => {
|
|
296
|
+
if (condition.value) {
|
|
297
|
+
console.log(dep.value) // Only tracked when condition=true
|
|
298
|
+
}
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
// ✅ Correct - use explicit watch for conditional deps
|
|
302
|
+
watch([condition, dep], ([cond, d]) => {
|
|
303
|
+
if (cond) console.log(d)
|
|
304
|
+
})
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### Cleanup Patterns
|
|
308
|
+
|
|
309
|
+
**For keep-alive components** - use `onDeactivated`:
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
export function usePolling() {
|
|
313
|
+
let interval: NodeJS.Timeout
|
|
314
|
+
|
|
315
|
+
onMounted(() => { interval = setInterval(poll, 5000) })
|
|
316
|
+
onUnmounted(() => clearInterval(interval))
|
|
317
|
+
onDeactivated(() => clearInterval(interval)) // Pause when deactivated
|
|
318
|
+
onActivated(() => { interval = setInterval(poll, 5000) }) // Resume
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
**For scope-aware cleanup** - use `tryOnScopeDispose` from VueUse:
|
|
323
|
+
|
|
324
|
+
```ts
|
|
325
|
+
import { tryOnScopeDispose } from '@vueuse/core'
|
|
326
|
+
|
|
327
|
+
export function useEventSource(url: string) {
|
|
328
|
+
const source = new EventSource(url)
|
|
329
|
+
|
|
330
|
+
// Cleans up when effect scope disposes (component unmount, watcher stop)
|
|
331
|
+
tryOnScopeDispose(() => source.close())
|
|
332
|
+
|
|
333
|
+
return { source }
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
## Common Mistakes
|
|
338
|
+
|
|
339
|
+
**Not using `readonly()` for internal state:**
|
|
340
|
+
|
|
341
|
+
```ts
|
|
342
|
+
// ❌ Wrong - exposes mutable ref
|
|
343
|
+
return { count }
|
|
344
|
+
|
|
345
|
+
// ✅ Correct - prevents external mutation
|
|
346
|
+
return { count: readonly(count) }
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
**Missing cleanup:**
|
|
350
|
+
|
|
351
|
+
```ts
|
|
352
|
+
// ❌ Wrong - listener never removed
|
|
353
|
+
onMounted(() => target.addEventListener('click', handler))
|
|
354
|
+
|
|
355
|
+
// ✅ Correct - cleanup on unmount
|
|
356
|
+
onMounted(() => target.addEventListener('click', handler))
|
|
357
|
+
onUnmounted(() => target.removeEventListener('click', handler))
|
|
358
|
+
```
|