Vue Patterns
Vue 3.5+ with <script setup lang="ts">. Always Composition API. Never Options API.
Principles
- TypeScript first — all props, emits, and composable returns fully typed
shallowRefoverreffor large objects (only.valueassignment triggers)- No Reactive Props Destructure — use
defineProps+withDefaults - Composables use
useprefix and return refs (not reactive objects) - One composable = one lifecycle concern
Script Setup Macros
See references/script-setup.md for full patterns.
Summary:
- defineProps — type-based:
defineProps<{ title: string }>(). Defaults viawithDefaults - defineEmits — named tuple:
defineEmits<{ update: [value: string] }>() - defineModel — two-way binding (Vue 3.4+):
const model = defineModel<string>() - defineExpose — explicitly expose to parent via template refs
- defineOptions —
inheritAttrs: false, customname - defineSlots — typed slot props
Reactivity
See references/reactivity.md for full patterns.
Summary:
reffor primitives and small objects.shallowReffor large data.computedfor derived state. Writable computed for two-way transforms.watchwithdeep: 2depth limit (3.5+),once: true(3.4+)watchEffectauto-tracks deps. UseonWatcherCleanup(3.5+) for teardown.toValue()(3.3+) to unwrapMaybeRefOrGetter<T>in composables.
Composable Pattern
// composables/useFeature.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useFeature(options: FeatureOptions) {
const data = ref<Data | null>(null)
const loading = ref(false)
async function load() {
loading.value = true
try {
data.value = await fetchData(options)
} finally {
loading.value = false
}
}
onMounted(load)
return { data, loading, reload: load } // return refs, not reactive
}
Module-level refs — for shared state across call sites, declare refs outside the function. Per-call state goes inside the function. Pinia stores are preferred for shared business state.
Built-in Components
- Transition —
<Transition name="fade">with CSS classes.fade-enter-active,.fade-leave-active - TransitionGroup — list animations,
keyrequired on children - Teleport —
<Teleport to="body">for modals/toasts.:disabledto render in-place. - Suspense — wraps async components (top-level
awaitin<script setup>) - KeepAlive —
:include/:excludeby component name,:maxcache limit
URL-State Sync
Views that persist selection state in the URL follow a two-part pattern: seed from URL on mount, watch store to update URL.
<script setup lang="ts">
import { onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const store = useFeatureStore()
onMounted(async () => {
// 1. Seed store from URL before initialize
const param = route.query.itemId
if (param && !isNaN(Number(param))) {
store.selectItem(Number(param)) // use store action, not direct mutation
}
await store.initialize()
})
// 2. Keep URL in sync when selection changes
watch(
() => store.selectedItemId,
(id) => {
if (id !== null) {
router.replace({ query: { ...route.query, itemId: String(id) } })
}
}
)
</script>
Rules:
- Use
router.replace(notpush) to avoid polluting browser history with filter changes. - Seed from URL before
initialize()so the first fetch uses the URL value. - Use store actions for mutations, not direct property assignment.
Recommended Packages
See references/recommended-packages.md for the approved ecosystem stack.
Project Conventions
- Types live in
client/src/types/index.ts - API modules in
client/src/api/{resource}.tsusingapiFetch - Stores in
client/src/stores/{name}Store.ts - Components in
client/src/components/ - Composables in
client/src/composables/ - Views in
client/src/views/{Name}View.vue RouterLinkandRouterVieware globally registered — no import needed
Source: ldumit/fokus — distributed by TomeVault.