Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Enforce Vue 3 best practices—Composition API, script setup, TypeScript, component boundaries, and reactivity patterns
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
references/composables.md
1---2title: Composable Organization Patterns3impact: MEDIUM4impactDescription: Well-structured composables improve maintainability, reusability, and update performance5type: best-practice6tags: [vue3, composables, composition-api, code-organization, api-design, readonly, utilities]7---89# Composable Organization Patterns1011**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.1213## Task List1415- Compose complex behavior from small, focused composables16- Use options objects for composables with multiple optional parameters17- Return readonly state when updates must flow through explicit actions18- Keep pure utility functions as plain utilities, not composables19- Organize composable and component code by feature concern, and extract composables when components grow2021## Compose Composables from Smaller Primitives2223**BAD:**24```vue25<script setup>26import { ref, computed, onMounted, onUnmounted } from 'vue'2728const x = ref(0)29const y = ref(0)30const inside = ref(false)31const el = ref(null)3233function onMove(e) {34x.value = e.pageX35y.value = e.pageY36if (!el.value) return37const r = el.value.getBoundingClientRect()38inside.value = x.value >= r.left && x.value <= r.right &&39y.value >= r.top && y.value <= r.bottom40}4142onMounted(() => window.addEventListener('mousemove', onMove))43onUnmounted(() => window.removeEventListener('mousemove', onMove))44</script>45```4647**GOOD:**48```javascript49// composables/useEventListener.js50import { onMounted, onUnmounted, toValue } from 'vue'5152export function useEventListener(target, event, callback) {53onMounted(() => toValue(target).addEventListener(event, callback))54onUnmounted(() => toValue(target).removeEventListener(event, callback))55}56```5758```javascript59// composables/useMouse.js60import { ref } from 'vue'61import { useEventListener } from './useEventListener'6263export function useMouse() {64const x = ref(0)65const y = ref(0)6667useEventListener(window, 'mousemove', (e) => {68x.value = e.pageX69y.value = e.pageY70})7172return { x, y }73}74```7576```javascript77// composables/useMouseInElement.js78import { computed } from 'vue'79import { useMouse } from './useMouse'8081export function useMouseInElement(elementRef) {82const { x, y } = useMouse()8384const isOutside = computed(() => {85if (!elementRef.value) return true86const rect = elementRef.value.getBoundingClientRect()87return x.value < rect.left || x.value > rect.right ||88y.value < rect.top || y.value > rect.bottom89})9091return { x, y, isOutside }92}93```9495## Use Options Object Pattern for Composable Parameters9697**BAD:**98```javascript99export function useFetch(url, method, headers, timeout, retries, immediate) {100// hard to read and easy to misorder101}102103useFetch('/api/users', 'GET', null, 5000, 3, true)104```105106**GOOD:**107```javascript108export function useFetch(url, options = {}) {109const {110method = 'GET',111headers = {},112timeout = 30000,113retries = 0,114immediate = true115} = options116117// implementation118return { method, headers, timeout, retries, immediate }119}120121useFetch('/api/users', {122method: 'POST',123timeout: 5000,124retries: 3125})126```127128```typescript129interface UseCounterOptions {130initial?: number131min?: number132max?: number133step?: number134}135136export function useCounter(options: UseCounterOptions = {}) {137const { initial = 0, min = -Infinity, max = Infinity, step = 1 } = options138// implementation139}140```141142## Return Readonly State with Explicit Actions143144**BAD:**145```javascript146export function useCart() {147const items = ref([])148const total = computed(() => items.value.reduce((sum, item) => sum + item.price, 0))149return { items, total } // any consumer can mutate directly150}151152const { items } = useCart()153items.value.push({ id: 1, price: 10 })154```155156**GOOD:**157```javascript158import { ref, computed, readonly } from 'vue'159160export function useCart() {161const _items = ref([])162163const total = computed(() =>164_items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)165)166167function addItem(product, quantity = 1) {168const existing = _items.value.find(item => item.id === product.id)169if (existing) {170existing.quantity += quantity171return172}173_items.value.push({ ...product, quantity })174}175176function removeItem(productId) {177_items.value = _items.value.filter(item => item.id !== productId)178}179180return {181items: readonly(_items),182total,183addItem,184removeItem185}186}187```188189## Keep Utilities as Utilities190191**BAD:**192```javascript193export function useFormatters() {194const formatDate = (date) => new Intl.DateTimeFormat('en-US').format(date)195const formatCurrency = (amount) =>196new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount)197return { formatDate, formatCurrency }198}199200const { formatDate } = useFormatters()201```202203**GOOD:**204```javascript205// utils/formatters.js206export function formatDate(date) {207return new Intl.DateTimeFormat('en-US').format(date)208}209210export function formatCurrency(amount) {211return new Intl.NumberFormat('en-US', {212style: 'currency',213currency: 'USD'214}).format(amount)215}216```217218```javascript219// composables/useInvoiceSummary.js220import { computed } from 'vue'221import { formatCurrency } from '@/utils/formatters'222223export function useInvoiceSummary(invoiceRef) {224const totalLabel = computed(() => formatCurrency(invoiceRef.value.total))225return { totalLabel }226}227```228229## Organize Composable and Component Code by Feature Concern230231**BAD:**232```vue233<script setup>234import { ref, computed, watch, onMounted } from 'vue'235236const searchQuery = ref('')237const items = ref([])238const selected = ref(null)239const showModal = ref(false)240const sortBy = ref('name')241const filter = ref('all')242const loading = ref(false)243244const filtered = computed(() => items.value.filter(i => i.category === filter.value))245function openModal() { showModal.value = true }246const sorted = computed(() => [...filtered.value].sort(/* ... */))247watch(searchQuery, () => { /* ... */ })248onMounted(() => { /* ... */ })249</script>250```251252**GOOD:**253```vue254<script setup>255import { useItems } from '@/composables/useItems'256import { useSearch } from '@/composables/useSearch'257import { useSelectionModal } from '@/composables/useSelectionModal'258259// Data260const { items, loading, fetchItems } = useItems()261262// Search/filter/sort263const { query, visibleItems } = useSearch(items)264265// Selection + modal266const { selectedItem, isModalOpen, selectItem, closeModal } = useSelectionModal()267</script>268```269270```javascript271// composables/useItems.js272import { ref, onMounted } from 'vue'273274export function useItems() {275const items = ref([])276const loading = ref(false)277278async function fetchItems() {279loading.value = true280try {281items.value = await api.getItems()282} finally {283loading.value = false284}285}286287onMounted(fetchItems)288return { items, loading, fetchItems }289}290```291