Initial commit
This commit is contained in:
290
.claude/skills/vue-best-practices/references/composables.md
Normal file
290
.claude/skills/vue-best-practices/references/composables.md
Normal file
@@ -0,0 +1,290 @@
|
||||
---
|
||||
title: Composable Organization Patterns
|
||||
impact: MEDIUM
|
||||
impactDescription: Well-structured composables improve maintainability, reusability, and update performance
|
||||
type: best-practice
|
||||
tags: [vue3, composables, composition-api, code-organization, api-design, readonly, utilities]
|
||||
---
|
||||
|
||||
# Composable Organization Patterns
|
||||
|
||||
**Impact: MEDIUM** - Treat composables as reusable, stateful building blocks and keep their code organized by feature concern. This keeps large components maintainable and prevents hard-to-debug mutation and API design issues.
|
||||
|
||||
## Task List
|
||||
|
||||
- Compose complex behavior from small, focused composables
|
||||
- Use options objects for composables with multiple optional parameters
|
||||
- Return readonly state when updates must flow through explicit actions
|
||||
- Keep pure utility functions as plain utilities, not composables
|
||||
- Organize composable and component code by feature concern, and extract composables when components grow
|
||||
|
||||
## Compose Composables from Smaller Primitives
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const x = ref(0)
|
||||
const y = ref(0)
|
||||
const inside = ref(false)
|
||||
const el = ref(null)
|
||||
|
||||
function onMove(e) {
|
||||
x.value = e.pageX
|
||||
y.value = e.pageY
|
||||
if (!el.value) return
|
||||
const r = el.value.getBoundingClientRect()
|
||||
inside.value = x.value >= r.left && x.value <= r.right &&
|
||||
y.value >= r.top && y.value <= r.bottom
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('mousemove', onMove))
|
||||
onUnmounted(() => window.removeEventListener('mousemove', onMove))
|
||||
</script>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```javascript
|
||||
// composables/useEventListener.js
|
||||
import { onMounted, onUnmounted, toValue } from 'vue'
|
||||
|
||||
export function useEventListener(target, event, callback) {
|
||||
onMounted(() => toValue(target).addEventListener(event, callback))
|
||||
onUnmounted(() => toValue(target).removeEventListener(event, callback))
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// composables/useMouse.js
|
||||
import { ref } from 'vue'
|
||||
import { useEventListener } from './useEventListener'
|
||||
|
||||
export function useMouse() {
|
||||
const x = ref(0)
|
||||
const y = ref(0)
|
||||
|
||||
useEventListener(window, 'mousemove', (e) => {
|
||||
x.value = e.pageX
|
||||
y.value = e.pageY
|
||||
})
|
||||
|
||||
return { x, y }
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// composables/useMouseInElement.js
|
||||
import { computed } from 'vue'
|
||||
import { useMouse } from './useMouse'
|
||||
|
||||
export function useMouseInElement(elementRef) {
|
||||
const { x, y } = useMouse()
|
||||
|
||||
const isOutside = computed(() => {
|
||||
if (!elementRef.value) return true
|
||||
const rect = elementRef.value.getBoundingClientRect()
|
||||
return x.value < rect.left || x.value > rect.right ||
|
||||
y.value < rect.top || y.value > rect.bottom
|
||||
})
|
||||
|
||||
return { x, y, isOutside }
|
||||
}
|
||||
```
|
||||
|
||||
## Use Options Object Pattern for Composable Parameters
|
||||
|
||||
**BAD:**
|
||||
```javascript
|
||||
export function useFetch(url, method, headers, timeout, retries, immediate) {
|
||||
// hard to read and easy to misorder
|
||||
}
|
||||
|
||||
useFetch('/api/users', 'GET', null, 5000, 3, true)
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```javascript
|
||||
export function useFetch(url, options = {}) {
|
||||
const {
|
||||
method = 'GET',
|
||||
headers = {},
|
||||
timeout = 30000,
|
||||
retries = 0,
|
||||
immediate = true
|
||||
} = options
|
||||
|
||||
// implementation
|
||||
return { method, headers, timeout, retries, immediate }
|
||||
}
|
||||
|
||||
useFetch('/api/users', {
|
||||
method: 'POST',
|
||||
timeout: 5000,
|
||||
retries: 3
|
||||
})
|
||||
```
|
||||
|
||||
```typescript
|
||||
interface UseCounterOptions {
|
||||
initial?: number
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
}
|
||||
|
||||
export function useCounter(options: UseCounterOptions = {}) {
|
||||
const { initial = 0, min = -Infinity, max = Infinity, step = 1 } = options
|
||||
// implementation
|
||||
}
|
||||
```
|
||||
|
||||
## Return Readonly State with Explicit Actions
|
||||
|
||||
**BAD:**
|
||||
```javascript
|
||||
export function useCart() {
|
||||
const items = ref([])
|
||||
const total = computed(() => items.value.reduce((sum, item) => sum + item.price, 0))
|
||||
return { items, total } // any consumer can mutate directly
|
||||
}
|
||||
|
||||
const { items } = useCart()
|
||||
items.value.push({ id: 1, price: 10 })
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```javascript
|
||||
import { ref, computed, readonly } from 'vue'
|
||||
|
||||
export function useCart() {
|
||||
const _items = ref([])
|
||||
|
||||
const total = computed(() =>
|
||||
_items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
||||
)
|
||||
|
||||
function addItem(product, quantity = 1) {
|
||||
const existing = _items.value.find(item => item.id === product.id)
|
||||
if (existing) {
|
||||
existing.quantity += quantity
|
||||
return
|
||||
}
|
||||
_items.value.push({ ...product, quantity })
|
||||
}
|
||||
|
||||
function removeItem(productId) {
|
||||
_items.value = _items.value.filter(item => item.id !== productId)
|
||||
}
|
||||
|
||||
return {
|
||||
items: readonly(_items),
|
||||
total,
|
||||
addItem,
|
||||
removeItem
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Keep Utilities as Utilities
|
||||
|
||||
**BAD:**
|
||||
```javascript
|
||||
export function useFormatters() {
|
||||
const formatDate = (date) => new Intl.DateTimeFormat('en-US').format(date)
|
||||
const formatCurrency = (amount) =>
|
||||
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount)
|
||||
return { formatDate, formatCurrency }
|
||||
}
|
||||
|
||||
const { formatDate } = useFormatters()
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```javascript
|
||||
// utils/formatters.js
|
||||
export function formatDate(date) {
|
||||
return new Intl.DateTimeFormat('en-US').format(date)
|
||||
}
|
||||
|
||||
export function formatCurrency(amount) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(amount)
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// composables/useInvoiceSummary.js
|
||||
import { computed } from 'vue'
|
||||
import { formatCurrency } from '@/utils/formatters'
|
||||
|
||||
export function useInvoiceSummary(invoiceRef) {
|
||||
const totalLabel = computed(() => formatCurrency(invoiceRef.value.total))
|
||||
return { totalLabel }
|
||||
}
|
||||
```
|
||||
|
||||
## Organize Composable and Component Code by Feature Concern
|
||||
|
||||
**BAD:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
|
||||
const searchQuery = ref('')
|
||||
const items = ref([])
|
||||
const selected = ref(null)
|
||||
const showModal = ref(false)
|
||||
const sortBy = ref('name')
|
||||
const filter = ref('all')
|
||||
const loading = ref(false)
|
||||
|
||||
const filtered = computed(() => items.value.filter(i => i.category === filter.value))
|
||||
function openModal() { showModal.value = true }
|
||||
const sorted = computed(() => [...filtered.value].sort(/* ... */))
|
||||
watch(searchQuery, () => { /* ... */ })
|
||||
onMounted(() => { /* ... */ })
|
||||
</script>
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```vue
|
||||
<script setup>
|
||||
import { useItems } from '@/composables/useItems'
|
||||
import { useSearch } from '@/composables/useSearch'
|
||||
import { useSelectionModal } from '@/composables/useSelectionModal'
|
||||
|
||||
// Data
|
||||
const { items, loading, fetchItems } = useItems()
|
||||
|
||||
// Search/filter/sort
|
||||
const { query, visibleItems } = useSearch(items)
|
||||
|
||||
// Selection + modal
|
||||
const { selectedItem, isModalOpen, selectItem, closeModal } = useSelectionModal()
|
||||
</script>
|
||||
```
|
||||
|
||||
```javascript
|
||||
// composables/useItems.js
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
export function useItems() {
|
||||
const items = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
items.value = await api.getItems()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchItems)
|
||||
return { items, loading, fetchItems }
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user