07 — Composition API In Depth
setup() vs script setup — Compilation Difference
SetupFunction.vue
<script>
import { ref, computed, onMounted } from 'vue'
// ── setup() function form: the original Composition API ──
// Runs BEFORE the component is created (before data(), before created hook).
// Must explicitly return everything the template needs.
// `this` is NOT available — no access to component instance.
export default {
props: { userId: Number },
setup(props) {
// props is reactive here — use props.userId, not this.userId
const count = ref(0)
const doubled = computed(() => count.value * 2)
function increment() { count.value++ }
onMounted(() => {
console.log('mounted, count is', count.value)
})
// ── Must return an object — template accesses these as top-level ──
// Anything not returned is invisible to the template.
return { count, doubled, increment }
}
}
</script>
<template>
<button @click="increment">{{ count }} ({{ doubled }})</button>
</template>
ScriptSetup.vue
<script setup>
import { ref, computed, onMounted } from 'vue'
// ── <script setup> is syntactic sugar over setup() ──
// The compiler auto-returns all top-level bindings to the template.
// defineProps/defineEmits are compiler macros (not real imports).
const props = defineProps<{ userId: number }>()
const count = ref(0)
const doubled = computed(() => count.value * 2)
function increment() { count.value++ }
onMounted(() => console.log('mounted'))
// No return needed — count, doubled, increment are auto-exposed.
// Only things explicitly imported remain private if not used in template.
</script>
<template>
<button @click="increment">{{ count }} ({{ doubled }})</button>
</template>
Composable — useFetch with Race Condition Prevention
useFetch.ts
import { ref, watchEffect, isRef, toValue, type MaybeRefOrGetter } from 'vue'
// ── Production-grade useFetch: handles race conditions, abort, SSR ──
// Accepts ref, getter, or plain string as URL source.
interface FetchState<T> {
data: Ref<T | null>
error: Ref<Error | null>
loading: Ref<boolean>
}
export function useFetch<T>(
source: MaybeRefOrGetter<string>,
options?: RequestInit
): FetchState<T> {
const data = ref<T | null>(null) as Ref<T | null>
const error = ref<Error | null>(null)
const loading = ref(false)
// watchEffect auto-tracks any reactive source read via toValue()
watchEffect(async (onCleanup) => {
const url = toValue(source) // unwrap ref/getter/plain
if (!url) return
const controller = new AbortController()
loading.value = true
error.value = null
// ── Race condition prevention: abort previous fetch on URL change ──
onCleanup(() => controller.abort())
try {
const res = await fetch(url, { ...options, signal: controller.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = await res.json() as T
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return
error.value = e as Error
} finally {
loading.value = false
}
})
return { data, error, loading }
}
Shared State — Module-Scoped Singleton
useSharedCounter.ts
import { ref, computed, readonly, type Ref } from 'vue'
// ── Module-scoped ref = singleton shared across ALL importers ──
// The ref is created ONCE at module evaluation, not per composable call.
// Every component that calls useSharedCounter() gets the same ref.
const count = ref(0) // module scope — ONE instance for the whole app
const doubled = computed(() => count.value * 2)
export function useSharedCounter() {
function increment() { count.value++ }
function decrement() { count.value-- }
function reset() { count.value = 0 }
return {
count: readonly(count), // expose read-only — prevent external mutation
doubled: readonly(doubled),
increment,
decrement,
reset,
}
}
// ── Why this works: ────────────────────────────────────
// ES modules are singletons — the module body runs once.
// `count` lives in module scope, shared by all callers.
// This is a lightweight alternative to Pinia for simple global state.
// For complex state (multiple stores, devtools, persistence), use Pinia.
// ── ⚠️ SSR caveat: ──────────────────────────────────────
// On the server, module state is shared across ALL requests.
// Request A's count leaks into Request B — a security/correctness bug.
// Fix: use Pinia (request-scoped stores) or reset module state in server entry.
Effect Scope Cleanup — Composable Teardown
useEventListener.ts
import { onScopeDispose, ref, type Ref } from 'vue'
// ── onScopeDispose: runs cleanup when the hosting scope stops ──
// In a component, the scope stops on unmount.
// In a composable called from setup(), the scope is the component's scope.
// This replaces manual onUnmounted cleanup for composables.
export function useEventListener(
target: EventTarget | Ref<EventTarget>,
event: string,
handler: (e: Event) => void
) {
// Get the target — handle both raw EventTarget and Ref<EventTarget>
const el = toValue(target)
el.addEventListener(event, handler)
// ── Cleanup registered declaratively — runs on scope dispose ──
// No need to return a cleanup function or call onUnmounted manually.
onScopeDispose(() => {
el.removeEventListener(event, handler)
})
}
// ── Usage in a component: ──────────────────────────────
// setup() creates a scope; onScopeDispose callbacks fire on unmount.
// const { x, y } = useMouse() // internally calls useEventListener
// Component unmounts → scope disposes → event listeners removed automatically.
useMouse.ts
import { ref, onScopeDispose } from 'vue'
export function useMouse() {
const x = ref(0)
const y = ref(0)
function handler(e: MouseEvent) {
x.value = e.clientX
y.value = e.clientY
}
window.addEventListener('mousemove', handler)
// ── Declarative cleanup — fires when the hosting component unmounts ──
onScopeDispose(() => {
window.removeEventListener('mousemove', handler)
})
return { x, y }
}
// ── Manual scope (outside component): ──────────────────
import { effectScope } from 'vue'
const scope = effectScope()
scope.run(() => {
const { x, y } = useMouse()
// listeners are active
})
scope.stop() // stops the scope → onScopeDispose fires → listeners removed
Async Setup — Suspense Integration
AsyncDataLoader.vue
<script setup>
import { ref } from 'vue'
// ── async setup() with top-level await ──
// Requires a <Suspense> boundary in the parent.
// While awaiting, the parent shows the fallback slot.
const data = ref(null)
// Top-level await pauses setup() — the component doesn't render until resolved.
// Parent: <Suspense><AsyncDataLoader /><template #fallback>Loading…</template></Suspense>
const res = await fetch('/api/initial-data')
data.value = await res.json()
</script>
<template>
<div>{{ data }}</div>
</template>
async-caveat.ts
// ── Async setup caveats: ────────────────────────────────
// 1. Top-level await in <script setup> makes the component async.
// It MUST be wrapped in <Suspense> or it throws.
// 2. Watchers and lifecycle hooks registered BEFORE the await
// still fire, but after the await they may not if the component
// is unmounted while still awaiting.
// 3. Error handling: use <Suspense> with error boundary, or
// onErrorCaptured in the parent.
// 4. For most cases, prefer useFetch() composable (non-blocking)
// over async setup — it's more flexible and doesn't require Suspense.
💡 Tips & Tricks
tips.ts
// ── 1. Composable naming convention: useXxx ──
// Enables auto-import in some setups, signals "this is a composable."
// ── 2. Always return refs, not values, from composables ──
// ✅ return { count: readonly(count) } — stays reactive in consumer
// ❌ return { count: count.value } — snapshot, not reactive
// ── 3. Accept MaybeRefOrGetter for flexibility ──
function useTimer(duration: MaybeRefOrGetter<number>) {
const ms = toValue(duration) // unwrap at call site, not definition
// Caller can pass: ref(5000), () => store.timeout, or 5000
}
// ── 4. Use shallowRef for large data inside composables ──
// Avoids deep reactivity proxy overhead for big arrays/objects.
// ── 5. Composables can use provide/inject internally ──
// Pattern: parent calls provideX(), descendants call useX() which injects.
// Decouples creation from consumption — clean DI.
⚠️ Edge Cases & Gotchas
edge-cases.ts
// ── 1. Module-scoped refs leak state on SSR ──
// Server: module state persists across requests → data leak.
// Fix: reset on server entry, or use Pinia (request-scoped).
// ── 2. onScopeDispose only works inside an active scope ──
// Calling onScopeDispose outside setup() or effectScope.run() throws.
// Don't call composables in setTimeout/callbacks — call in setup().
// ── 3. <script setup> is closed — no this, no $emit, no $parent ──
// Everything is top-level. Use defineExpose for parent access via refs.
// For programmatic navigation: import useRouter/useRoute, not this.$router.
// ── 4. setup() runs before created/data — no access to Options API state ──
// Can't read this.$options, this.$data in setup(). Use Composition API only.
// ── 5. async <script setup> requires <Suspense> ──
// Without <Suspense>, the component throws and the app crashes.
// Non-async alternatives: useFetch() in onMounted, watchEffect, etc.
// ── 6. Composable cleanup with onUnmounted vs onScopeDispose ──
// onScopeDispose: works in composables AND effectScope (more general).
// onUnmounted: only fires on component unmount (component-only).
// Prefer onScopeDispose in composable implementations.
🧠 Spot the Bug
A composable's event listener is never removed, causing a memory leak.
LeakBug.ts
import { ref, onMounted } from 'vue'
export function useMouse() {
const x = ref(0)
const y = ref(0)
onMounted(() => {
window.addEventListener('mousemove', (e) => {
x.value = e.clientX
y.value = e.clientY
})
})
return { x, y }
}
Answer
The event listener is added but never removed. When the component unmounts, the listener persists, holding references to the component's x and y refs, preventing garbage collection.
Fix — register cleanup via onScopeDispose:
LeakFixed.ts
import { ref, onScopeDispose } from 'vue'
export function useMouse() {
const x = ref(0)
const y = ref(0)
function handler(e: MouseEvent) {
x.value = e.clientX
y.value = e.clientY
}
window.addEventListener('mousemove', handler)
onScopeDispose(() => window.removeEventListener('mousemove', handler))
return { x, y }
}
The lesson: every external resource (event listener, timer, WebSocket, subscription) registered in a composable must have a cleanup handler via onScopeDispose. Without it, the resource leaks past the component's lifetime.