GAP·MAP

Vue Composition API

[ junior depth ]

What <script setup> is

<script setup> is compile-time sugar over the Composition API for single-file components. The block runs as the component's setup(), once for every instance created. Every top-level binding, variables, functions, and imports, is exposed to the template, so you never write a return.

<script setup>
import { ref } from "vue"
const count = ref(0)
function inc() { count.value++ }
</script>

<template>
  <button @click="inc">{{ count }}</button>
</template>

Imported components work by name in the template too, with no local registration step.

Declaring props and events

Inside <script setup> you declare inputs with defineProps and outputs with defineEmits.

<script setup>
const props = defineProps(["title"])
const emit = defineEmits(["close"])
</script>

Wrong import { defineProps } from "vue". These are compiler macros, not runtime functions. They exist only inside <script setup>, and importing them is unnecessary (Vue warns).

Right call them directly with no import. The compiler turns each call into the component's real props and emits declarations.

Running code when the component mounts

Lifecycle hooks let you run code when a component mounts, updates, or unmounts. Import them from vue and call them in setup.

import { onMounted, onUnmounted } from "vue"

onMounted(() => {
  // the DOM now exists: safe to read a template ref or start a timer
})
onUnmounted(() => {
  // clean up timers and listeners here so nothing leaks
})

onMounted fires after the component's DOM is in the page, which makes it the spot for anything that touches the DOM. There is no created hook in the Composition API, because the body of <script setup> already runs at that phase.

[ middle depth ]

Designing a composable that stays reactive

A composable is a function that packages stateful logic with the Composition API. Name it in camelCase starting with use: useMouse, useFetch. Return a plain object of refs so the caller can destructure without losing reactivity.

export function useMouse() {
  const x = ref(0)
  const y = ref(0)
  // ...update x.value / y.value on mousemove
  return { x, y }              // plain object of refs
}
// caller: const { x, y } = useMouse()  -> both stay reactive

Wrong return reactive({ x, y }). The moment the caller writes const { x, y } = useMouse(), destructuring reads the values out of the proxy and the link is severed, the same failure as destructuring any reactive object.

Right return the refs. A caller who wants dotted access can wrap it: const mouse = reactive(useMouse()), and mouse.x stays live.

Accepting a ref, a getter, or a plain value

A reusable composable should not force one exact input shape. Type the parameter MaybeRefOrGetter<T> and normalize with toValue, which unwraps a ref and also calls a getter.

function useFetch(url) {              // string | Ref<string> | (() => string)
  const data = ref(null)
  watchEffect(() => {
    fetch(toValue(url)).then(/* ... */)   // toValue INSIDE the effect
  })
  return { data }
}

Calling toValue(url) inside watchEffect is what registers the dependency, so the fetch re-runs when a reactive url changes. Read it once outside the effect and you capture a snapshot that never updates.

Why you cannot mutate a prop

Props are read-only and flow one way. Assigning props.foo = x warns and does not stick. To derive from a prop use a computed; to seed local editable state, copy the initial value into a ref.

const props = defineProps(["initialCount"])
const count = ref(props.initialCount)          // initial value only
const doubled = computed(() => props.initialCount * 2)

Registering lifecycle hooks synchronously

onMounted and its siblings need the active component instance, which Vue provides only during synchronous setup. A hook registered after an await, or inside an if, quietly fails to attach. Register it unconditionally at the top of setup, then put the branching inside the callback.

[ senior depth ]

Reactive props destructure and what still breaks in 3.5

Vue 3.5 stabilized reactive props destructure and turned it on by default. const { title } = defineProps<Props>() stays reactive because the compiler rewrites every in-scope title to props.title. Default values move to native syntax, which retires withDefaults:

const { size = "md", tags = [] } = defineProps<Props>()

An object or array literal is safe as a default here; the compiler wraps each in a per-instance factory, so the old withDefaults(() => []) idiom is no longer needed.

That rewrite reaches only accesses inside the same <script setup> scope. Hand the bare variable to anything outside the scope and you pass a value snapshot:

watch(title, cb)          // wrong: watches the current string, Vue warns
watch(() => title, cb)    // right: a getter Vue can re-read
useThing(() => title)     // pass a getter; the composable calls toValue

Before 3.5 the destructured binding was a plain constant, so a watchEffect reading it ran once and never re-ran.

Why composables must be called synchronously

onMounted, provide, and inject all bind to the active component instance, which Vue exposes only during synchronous setup. After an await outside <script setup>, that context is gone: these calls no-op or dev-warn instead of registering. watch and watchEffect behave differently. They still create a working effect with no active instance, but they lose the automatic disposal that stops them on unmount, so one registered after the await leaks. The exception is top-level await in <script setup>: Vue restores the instance after the await, so hooks registered later still attach. This is why a composable that calls onUnmounted has to be invoked directly in setup, never behind a callback or a conditional.

provide/inject without leaking mutations

provide(key, value) in an ancestor and inject(key) in any descendant skip prop drilling. In a large app or a library, use a Symbol key typed with InjectionKey<T> to avoid string collisions and get inference on both ends.

const CartKey = Symbol() as InjectionKey<Cart>

Injected refs are not auto-unwrapped the way template refs are, so you read .value in script. Keep writes in the provider: hand down { cart, addItem } and mutate only through addItem, or provide readonly(cart) to block injector writes outright. For app-wide shared state, reach for Pinia rather than a tree of providers.

Choosing Composition over Options

The migration case is logic reuse without mixins (explicit inputs and outputs, no key collisions), co-locating one concern instead of scattering it across data, methods, and watch, and cleaner TypeScript inference. Both APIs are permanent and they interoperate, so migration goes component by component, and a setup() can sit inside an Options component during the transition. The Options API stays a reasonable default for low-to-medium complexity components.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more Vue

was this useful?