Back to Read More
VueJavaScript

Getting Started with Vue 3 Composition API

Jan 15, 2026

The Composition API is the modern way to write Vue components. Instead of organizing code by options (data, methods, computed, watch), you organize code by logical concern β€” keeping related logic together. This makes components easier to read, reuse, and maintain.

Options API vs Composition API

Options API (old way)
  • Code split by type (data, methods, computed)
  • Related logic scattered across sections
  • Hard to extract reusable logic
  • Uses this keyword
Composition API (new way)
  • Code grouped by feature
  • Related logic stays together
  • Easy to extract into composables
  • No this β€” plain variables

1. script setup

<script setup> is the recommended way to use the Composition API in Single File Components. Everything declared at the top level is automatically available in the template.

Basic Component
<script setup>
import { ref } from 'vue'

const message = ref('Hello Vue 3!')
const count = ref(0)

function increment() {
  count.value++
}
</script>

<template>
  <h1>{{ message }}</h1>
  <p>Count: {{ count }}</p>
  <button @click="increment">+1</button>
</template>
No return needed! With <script setup>, all variables, functions, and imports are automatically exposed to the template. No need for export default or return.

2. Reactive State: ref() & reactive()

ref() β€” for primitives & any value

ref() wraps a value in a reactive object. Access it with .value in script, but directly in template.

ref() usage
import { ref } from 'vue'

// String
const name = ref('John')

// Number
const count = ref(0)

// Boolean
const isVisible = ref(true)

// Array
const items = ref(['Apple', 'Banana', 'Cherry'])

// In script: use .value
count.value++
name.value = 'Jane'
items.value.push('Date')

// In template: no .value needed!
// {{ count }}  {{ name }}  {{ items }}

reactive() β€” for objects

reactive() makes an entire object reactive. No .value needed, but it only works with objects.

reactive() usage
import { reactive } from 'vue'

const user = reactive({
  name: 'John',
  age: 25,
  address: {
    city: 'Phnom Penh',
    country: 'Cambodia'
  }
})

// No .value needed!
user.name = 'Jane'
user.age = 26
user.address.city = 'Siem Reap'
ref()
  • Works with any type
  • Need .value in script
  • Can reassign entirely
  • Recommended for most cases
reactive()
  • Only objects/arrays
  • No .value needed
  • Cannot reassign the whole object
  • Good for complex state objects

3. Computed Properties

computed() creates a cached value that automatically updates when its dependencies change. Use it for derived data.

computed() usage
import { ref, computed } from 'vue'

const firstName = ref('John')
const lastName = ref('Doe')

// Computed β€” auto-updates when firstName or lastName changes
const fullName = computed(() => {
  return `${firstName.value} ${lastName.value}`
})

// Filtered list example
const todos = ref([
  { text: 'Learn Vue', done: true },
  { text: 'Build app', done: false },
  { text: 'Deploy', done: false },
])

const activeTodos = computed(() => {
  return todos.value.filter(t => !t.done)
})
// activeTodos.value = [{ text: 'Build app' }, { text: 'Deploy' }]

4. Watchers

watch() runs a callback when a reactive value changes. Use it for side effects (API calls, logging, etc).

watch() usage
import { ref, watch, watchEffect } from 'vue'

const search = ref('')
const userId = ref(1)

// Watch a single ref
watch(search, (newValue, oldValue) => {
  console.log(`Search changed: "${oldValue}" β†’ "${newValue}"`)
})

// Watch with options
watch(search, (newValue) => {
  // Call API after user stops typing
  fetchResults(newValue)
}, { debounce: 300 })

// Watch multiple sources
watch([firstName, lastName], ([newFirst, newLast]) => {
  console.log(`Name: ${newFirst} ${newLast}`)
})

// Deep watch (for objects)
watch(user, (newUser) => {
  console.log('User changed:', newUser)
}, { deep: true })

// watchEffect β€” auto-tracks dependencies
watchEffect(() => {
  // Runs immediately, then re-runs when userId changes
  console.log(`Fetching user ${userId.value}`)
  fetchUser(userId.value)
})

5. Lifecycle Hooks

Lifecycle hooks let you run code at specific points in a component's life.

HookWhen it runsCommon use
onMountedAfter DOM is renderedFetch data, init libraries
onUpdatedAfter reactive state change causes re-renderDOM-dependent operations
onUnmountedComponent is removedCleanup (timers, listeners)
onBeforeMountBefore DOM is renderedPre-render logic
onBeforeUnmountBefore component is removedSave state, cleanup
Lifecycle Hooks
import { onMounted, onUnmounted, ref } from 'vue'

const data = ref(null)
let intervalId = null

// Runs after component is mounted to DOM
onMounted(async () => {
  // Fetch data from API
  const res = await fetch('/api/data')
  data.value = await res.json()

  // Start a timer
  intervalId = setInterval(() => {
    console.log('tick')
  }, 1000)
})

// Cleanup when component is destroyed
onUnmounted(() => {
  clearInterval(intervalId)
})

6. Props & Emits

Props β€” receive data from parent

defineProps
// Child component: UserCard.vue
<script setup>
// Define what props this component accepts
const props = defineProps<{
  name: string
  age: number
  avatar?: string  // optional prop
}>()

// Use props directly
console.log(props.name)
</script>

<template>
  <div class="card">
    <img :src="avatar" />
    <h2>{{ name }}</h2>
    <p>Age: {{ age }}</p>
  </div>
</template>

Emits β€” send events to parent

defineEmits
// Child component: SearchInput.vue
<script setup>
const emit = defineEmits<{
  search: [query: string]
  clear: []
}>()

const query = ref('')

function onSearch() {
  emit('search', query.value)
}

function onClear() {
  query.value = ''
  emit('clear')
}
</script>

<template>
  <input v-model="query" @keyup.enter="onSearch" />
  <button @click="onSearch">Search</button>
  <button @click="onClear">Clear</button>
</template>

Using the component

Parent component
<!-- Parent component -->
<template>
  <UserCard
    name="John"
    :age="25"
    avatar="/john.jpg"
  />

  <SearchInput
    @search="handleSearch"
    @clear="handleClear"
  />
</template>

<script setup>
function handleSearch(query) {
  console.log('Searching for:', query)
}

function handleClear() {
  console.log('Search cleared')
}
</script>

7. Composables (Reusable Logic)

Composables are functions that encapsulate and reuse stateful logic. Convention: name them use[Something].

composables/useCounter.ts
// composables/useCounter.ts
import { ref, computed } from 'vue'

export function useCounter(initial = 0) {
  const count = ref(initial)

  const doubled = computed(() => count.value * 2)
  const isPositive = computed(() => count.value > 0)

  function increment() { count.value++ }
  function decrement() { count.value-- }
  function reset() { count.value = initial }

  return { count, doubled, isPositive, increment, decrement, reset }
}
Using the composable
<script setup>
import { useCounter } from '~/composables/useCounter'

// Use the composable β€” each instance has its own state
const { count, doubled, increment, decrement, reset } = useCounter(10)

// Can use multiple composables
const likes = useCounter(0)
const views = useCounter(100)
</script>

<template>
  <p>Count: {{ count }} (doubled: {{ doubled }})</p>
  <button @click="increment">+</button>
  <button @click="decrement">-</button>
  <button @click="reset">Reset</button>
</template>
Real-world examples: useFetch(), useAuth(), useTheme(), useLocalStorage(). Composables are Vue's answer to React hooks.

8. Template Refs

Access DOM elements directly using ref + template ref attribute.

Template Refs
<script setup>
import { ref, onMounted } from 'vue'

// Create a ref with null initial value
const inputRef = ref<HTMLInputElement | null>(null)

onMounted(() => {
  // Access the DOM element after mount
  inputRef.value?.focus()
})

function selectAll() {
  inputRef.value?.select()
}
</script>

<template>
  <!-- Connect with ref="inputRef" -->
  <input ref="inputRef" type="text" placeholder="Auto-focused!" />
  <button @click="selectAll">Select All</button>
</template>

9. Provide / Inject

Pass data deep through component trees without prop drilling.

Parent (provide)
Child
Grandchild (inject)
Parent β€” provide
<!-- Parent.vue -->
<script setup>
import { provide, ref } from 'vue'

const theme = ref('dark')
const user = ref({ name: 'John', role: 'admin' })

// Provide values β€” available to ALL descendants
provide('theme', theme)
provide('user', user)
</script>
Any descendant β€” inject
<!-- Any deeply nested child -->
<script setup>
import { inject } from 'vue'

// Inject values from any ancestor
const theme = inject('theme')        // ref('dark')
const user = inject('user')          // ref({ name: 'John', role: 'admin' })

// With default value (in case no ancestor provides it)
const lang = inject('lang', 'en')
</script>

<template>
  <div :class="theme">
    <p>Welcome, {{ user.name }}</p>
  </div>
</template>

Summary

  • βœ“script setup β€” cleaner syntax, auto-exposed variables
  • βœ“ref() / reactive() β€” make data reactive
  • βœ“computed() β€” cached derived values
  • βœ“watch() β€” react to changes with side effects
  • βœ“Lifecycle hooks β€” onMounted, onUnmounted, etc.
  • βœ“defineProps / defineEmits β€” component communication
  • βœ“Composables β€” reusable stateful logic (use[Something])
  • βœ“provide / inject β€” pass data without prop drilling

Β© 2026 Koeuk Dev. All rights reserved.

Built with Nuxt.js, Vue.js & Tailwind CSS