Skip to main content

βš™οΈ Composition API Fundamentals

The Composition API is Vue 3's flexible, function-based way to write components. Instead of scattering a feature across data, methods, and computed buckets, you keep everything about a feature together β€” and you can lift that logic straight out into a reusable function. This lesson covers reactivity, derived state, watchers, lifecycle, template refs, and composables.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Create reactive state with ref and reactive and explain when to reach for each
  • Derive state with computed, including writable computed properties
  • Run side effects with watch and watchEffect
  • Use Composition-API lifecycle hooks and template refs to access the DOM
  • Extract and reuse stateful logic by writing your own composables

Estimated Time: 45–55 minutes  β€’  Difficulty: Intermediate

Hands-on: Write a useCounter composable and use it in a component.

In This Lesson

Why the Composition API?

The Composition API is a set of functions β€” ref, computed, watch, the on* hooks β€” that you call to build a component's logic. It exists to solve a real pain in the older Options API: as a component grows, the code for one feature gets smeared across separate data, methods, computed, and watch sections, forcing you to scroll around to understand a single behavior.

πŸ’‘ A useful analogy: The Options API sorts your belongings by type β€” all cables in one drawer, all chargers in another, all manuals in a third. The Composition API sorts by purpose β€” everything for your camera in one box, everything for your laptop in another. When you need the camera, you grab one box instead of raiding three drawers.
flowchart LR subgraph Options["Options API β€” split by type"] O1[data] --- O2[methods] O2 --- O3[computed] O3 --- O4[watch] end subgraph Comp["Composition API β€” grouped by feature"] C1[Search feature: state + logic] C2[Cart feature: state + logic] C3[User feature: state + logic] end

The Options API organizes by option type; the Composition API keeps each feature's code together.

πŸ’‘ Not a replacement

The Composition API doesn't kill the Options API β€” both are fully supported in Vue 3. But for new code, especially anything non-trivial, the Composition API with <script setup> is the recommended default, and it's what this course uses throughout.

script setup

The <script setup> block is the modern, compile-time-optimized entry point for the Composition API. Every top-level binding you declare β€” variables, functions, imported components β€” is automatically available in the template. No return statement, no boilerplate.

<template>
  <p>Count: {{ count }}</p>
  <p>Double: {{ doubleCount }}</p>
  <button @click="increment">Increment</button>
</template>

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

const count = ref(0)
const doubleCount = computed(() => count.value * 2)

function increment() {
  count.value++
}

onMounted(() => console.log('mounted'))
// Everything above is auto-exposed to the template
</script>

πŸ“– The older setup() function

You may see the Composition API written as a setup() function inside a normal export default, which must explicitly return what the template uses. It works, but <script setup> is more concise, faster at runtime, and better for tooling β€” prefer it for new components.

ref and reactive

Reactivity is the engine that re-renders your UI when data changes. The Composition API gives you two ways to create reactive state.

ref() β€” a reactive box for any value

ref() wraps a value in a reactive container with a single .value property. It works for primitives and objects. You read and write .value in JavaScript, but in templates Vue unwraps it for you automatically.

import { ref } from 'vue'

const count = ref(0)
console.log(count.value)   // 0
count.value++              // reactive update
console.log(count.value)   // 1

const user = ref({ name: 'Ada', age: 30 })
user.value.age = 31        // still reactive
πŸ’‘ What is a ref, really? Think of a museum display case. The case itself stays put, but when you swap the artifact inside, sensors notice and update the information panel. A ref is that case: .value is the artifact, and Vue is the sensor that refreshes the UI whenever it changes.

⚠️ The .value gotcha

In <script> you must use .value to read or write a ref. In the <template> you must not β€” {{ count }}, not {{ count.value }}. Forgetting .value in JavaScript is the single most common beginner mistake.

reactive() β€” a deeply reactive object

reactive() makes an entire object reactive with no .value needed. It only works on objects, arrays, and collections β€” not primitives.

import { reactive } from 'vue'

const state = reactive({
  name: 'Ada',
  address: { city: 'London' }
})

state.name = 'Grace'            // reactive
state.address.city = 'New York' // nested changes are reactive too

Which should I use?

Reach for ref when…Reach for reactive when…
Working with a primitive (string, number, boolean)Grouping several related fields into one object
You may reassign the whole valueYou only mutate properties, never replace the object
Passing a reactive value into a function or composableYou want natural obj.prop syntax without .value

βœ… A common convention

Many teams use ref for almost everything, primitives and objects alike, for consistency and to sidestep reactive's pitfalls. That's a perfectly good default.

⚠️ Don't destructure a reactive object

Destructuring pulls values out of the reactive proxy and breaks the connection:

const state = reactive({ name: 'Ada', age: 30 })
let { name } = state   // ❌ 'name' is now a plain, non-reactive string

Use toRefs to keep each property reactive when you destructure:

import { reactive, toRefs } from 'vue'

const state = reactive({ name: 'Ada', age: 30 })
const { name, age } = toRefs(state)
name.value = 'Grace'   // βœ… updates state.name

Computed Properties

computed() creates derived state β€” a value calculated from other reactive values that updates automatically and caches its result until a dependency changes.

import { ref, computed } from 'vue'

const firstName = ref('Grace')
const lastName = ref('Hopper')

const fullName = computed(() => `${firstName.value} ${lastName.value}`)

console.log(fullName.value)   // 'Grace Hopper'
firstName.value = 'Ada'
console.log(fullName.value)   // 'Ada Hopper' β€” recalculated automatically

πŸ’‘ Computed vs. method

A computed caches: it only re-runs when a dependency changes, so reading it repeatedly is cheap. A method re-runs on every single render. Use computed for derived values, methods for actions triggered by events.

Writable computed

Pass an object with get and set to make a computed writable β€” handy when a single derived value maps back onto several sources:

const fullName = computed({
  get() {
    return `${firstName.value} ${lastName.value}`
  },
  set(value) {
    const parts = value.split(' ')
    firstName.value = parts[0]
    lastName.value = parts[parts.length - 1]
  }
})

fullName.value = 'Katherine Johnson'
console.log(firstName.value)  // 'Katherine'
console.log(lastName.value)   // 'Johnson'

Watchers

Where computed produces a value, a watcher runs a side effect β€” fetching data, writing to localStorage, logging β€” in response to changes.

watch: react to a specific source

import { ref, watch } from 'vue'

const query = ref('')
const results = ref([])

watch(query, async (newQuery, oldQuery) => {
  if (!newQuery.trim()) {
    results.value = []
    return
  }
  const res = await fetch(`/api/search?q=${encodeURIComponent(newQuery)}`)
  results.value = await res.json()
})

You can watch multiple sources at once, watch deeply for nested changes, or run immediately on setup:

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

// Deep watch a reactive object + run once immediately
watch(user, (value) => save(value), { deep: true, immediate: true })

watchEffect: track dependencies automatically

watchEffect runs immediately and re-runs whenever any reactive value it used changes β€” you never list dependencies explicitly.

import { ref, watchEffect } from 'vue'

const userId = ref(1)
const userData = ref(null)

watchEffect(async () => {
  // userId.value is read here, so this re-runs whenever it changes
  const res = await fetch(`/api/users/${userId.value}`)
  userData.value = await res.json()
})

userId.value = 2   // triggers the effect again

πŸ“– watch vs. watchEffect

Use watch when you need the old and new values, or want the effect to run only when a specific source changes. Use watchEffect for effects that depend on several values and should simply stay in sync. Both return a stop function β€” call it to cancel the watcher early.

Lifecycle & Template Refs

The Composition API exposes lifecycle hooks as imported on* functions. There's no onCreated β€” the top level of <script setup> is the creation phase.

<script setup>
import { onMounted, onBeforeUnmount } from 'vue'

// Code here runs during creation (replaces beforeCreate/created)

onMounted(() => {
  console.log('DOM is ready')
})

onBeforeUnmount(() => {
  console.log('cleaning up before removal')
})
</script>

Template refs

To reach an actual DOM element, declare a ref whose name matches the element's ref attribute. It's null until the component mounts, so access it inside onMounted.

<template>
  <input ref="inputEl" />
  <button @click="focusInput">Focus</button>
</template>

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

const inputEl = ref(null)

onMounted(() => {
  inputEl.value.focus()   // the element exists now
})

function focusInput() {
  inputEl.value.focus()
}
</script>

πŸ’‘ Why null at first?

The ref only gets connected to the real element after the template renders. Reading it at the top of <script setup> gives null; reading it in onMounted (or an event handler that fires later) gives the element.

Composables: Reusing Logic

The headline payoff of the Composition API is the composable: a plain function that uses Composition-API primitives to package stateful logic so you can reuse it across components. By convention, composables are named useSomething.

πŸ’‘ A useful analogy: Composables are like kitchen appliances. A blender does one job well and shows up in many recipes. Extract "counting" or "fetching" into a composable once, and every component can plug it in without re-implementing it.

Writing a composable

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

export function useCounter(initial = 0, step = 1) {
  const count = ref(initial)
  const doubled = computed(() => count.value * 2)

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

  return { count, doubled, increment, decrement, reset }
}

Using it in a component

<template>
  <p>Count: {{ count }} (doubled: {{ doubled }})</p>
  <button @click="increment">+</button>
  <button @click="decrement">-</button>
  <button @click="reset">Reset</button>
</template>

<script setup>
import { useCounter } from './composables/useCounter'

const { count, doubled, increment, decrement, reset } = useCounter(10, 2)
</script>

A more useful example: useFetch

// composables/useFetch.js
import { ref, watchEffect, toValue, isRef } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  const loading = ref(false)

  async function run() {
    loading.value = true
    error.value = null
    try {
      const res = await fetch(toValue(url))   // accepts a ref OR a string
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      data.value = await res.json()
    } catch (err) {
      error.value = err.message
    } finally {
      loading.value = false
    }
  }

  // If url is reactive, re-fetch when it changes; otherwise fetch once
  if (isRef(url)) watchEffect(run)
  else run()

  return { data, error, loading, refresh: run }
}

βœ… Composables vs. Vue 2 mixins

Composables fix the classic problems of mixins: the source of every value is an explicit import, naming collisions vanish because you rename on destructure, and dependencies are clear. They're the standard way to share logic in Vue 3. (The community library VueUse ships hundreds of ready-made ones.)

Hands-on Exercise

πŸ‹οΈ Write and Use a useToggle Composable

Objective: Practice extracting reusable stateful logic β€” the heart of the Composition API.

Requirements:

  1. Create composables/useToggle.js exporting a useToggle(initial = false) function.
  2. It should hold a boolean ref and expose toggle(), plus explicit setOn() and setOff() helpers.
  3. Return the state and the three functions.
  4. In a component, use it to show/hide a details panel with a single button.
  5. Prove reuse: call useToggle a second time in the same component for an unrelated switch (e.g. dark mode) without any logic duplication.
πŸ’‘ Hint

Because a composable returns fresh state on each call, invoking useToggle() twice gives you two independent booleans. Rename them on destructure: const { state: showDetails, toggle: toggleDetails } = useToggle().

βœ… Solution
// composables/useToggle.js
import { ref } from 'vue'

export function useToggle(initial = false) {
  const state = ref(initial)
  const toggle = () => { state.value = !state.value }
  const setOn  = () => { state.value = true }
  const setOff = () => { state.value = false }
  return { state, toggle, setOn, setOff }
}
<template>
  <button @click="toggleDetails">
    {{ showDetails ? 'Hide' : 'Show' }} details
  </button>
  <p v-if="showDetails">Here are the details!</p>

  <button @click="toggleDark">Toggle dark mode</button>
  <p>Dark mode is {{ dark ? 'on' : 'off' }}</p>
</template>

<script setup>
import { useToggle } from './composables/useToggle'

// Two independent instances β€” no duplicated logic
const { state: showDetails, toggle: toggleDetails } = useToggle()
const { state: dark, toggle: toggleDark } = useToggle(false)
</script>

Best Practices

βœ… Do🚫 Don't
Remember .value for refs in <script>Write count.value in the template (use {{ count }})
Use computed for derived valuesRecompute the same value in a method on every render
Use toRefs when destructuring a reactive objectDestructure a reactive object directly (breaks reactivity)
Extract shared logic into useX composablesCopy-paste the same ref/watch logic between components
Access template refs inside onMountedRead a template ref at the top of <script setup> (it's null)

🎯 Quick Quiz

Question 1: Inside <script setup>, how do you read and update a ref called count?

Question 2: You need a value that stays in sync with two refs and re-computes only when they change. Which tool fits?

Question 3: What is the main advantage of extracting logic into a composable?

Summary & Quiz

πŸŽ‰ Key Takeaways

  • The Composition API groups a feature's code together and makes logic reusable; write it in <script setup>.
  • ref wraps any value (use .value in JS); reactive makes objects deeply reactive β€” don't destructure it without toRefs.
  • computed gives cached derived state; watchers (watch, watchEffect) run side effects.
  • Lifecycle hooks are on* functions; template refs reach the DOM and are ready in onMounted.
  • Composables (useX functions) are the standard way to extract and share stateful logic.

πŸ“š Further Reading

πŸš€ What's Next?

You've now covered Vue's core. Next we pivot to the other framework in this module: Angular Framework Architecture β€” a look at how Angular structures an application with standalone components, signals, and dependency injection.

πŸŽ‰ Nice work!

You've got the Composition API in hand β€” the foundation of modern Vue. On to Angular.