Deep Dive into Vue 3 Composition API Patterns

Last updated: April 13, 2025

1. Introduction: The Power of Composition API

Vue 3 introduced the Composition API as a flexible alternative to the traditional Options API for organizing component logic. It addresses limitations of the Options API in large components and enhances logic reuse and TypeScript integration. While the Options API remains valid, the Composition API, especially when used with the <script setup> syntax, enables powerful patterns for building scalable and maintainable Vue applications. This article explores key patterns you can leverage with the Composition API.

2. Core Reactivity Patterns (<script setup>)

The foundation of the Composition API is its reactivity system. <script setup> provides a concise way to declare reactive state directly within the script block.

2.1 ref: Reactivity for Any Value

ref is used to create a reactive variable for any value type, including primitives (strings, numbers, booleans) and objects. To access or modify the value of a ref, you must use its .value property within the script block. In the template, Vue automatically unwraps the ref, so .value is not needed.

<script setup>
import { ref } from 'vue';

const count = ref(0); // Reactive reference for a number
const message = ref('Hello Vue!'); // Reactive reference for a string

function increment() {
  count.value++; // Access and modify using .value
}
</script>

<template>
  <div>
    <p>{{ message }}</p>
    <p>Count: {{ count }}</p> <!-- No .value needed in template -->
    <button @click="increment">Increment</button>
  </div>
</template>

2.2 reactive: Reactivity for Objects

reactive is specifically designed to make JavaScript objects deeply reactive. Unlike ref, you don't need .value to access or modify properties of a reactive object. However, reactive has limitations: it only works for object types (Objects, Arrays, Map, Set) and loses reactivity if the entire object is replaced.

<script setup>
import { reactive } from 'vue';

const user = reactive({
  name: 'Alice',
  age: 30,
  address: {
    city: 'Wonderland'
  }
});

function celebrateBirthday() {
  user.age++; // Direct mutation works
}

function changeCity(newCity) {
    user.address.city = newCity; // Deep reactivity works
}

// Caution: Replacing the entire object breaks reactivity
// function replaceUser() {
//  user = reactive({ name: 'Bob', age: 40 }); // This does NOT work as expected
// }
</script>

<template>
  <div>
    <p>Name: {{ user.name }}</p>
    <p>Age: {{ user.age }}</p>
    <p>City: {{ user.address.city }}</p>
    <button @click="celebrateBirthday">Birthday!</button>
    <button @click="changeCity('Metropolis')">Move City</button>
  </div>
</template>

Generally, ref is recommended as the primary choice for declaring reactive state due to its flexibility, even for objects.

3. Computed Properties (computed)

Computed properties allow you to declaratively create derived values based on other reactive state. They are cached based on their reactive dependencies and only re-evaluate when needed.

3.1 Deriving State Efficiently

Use computed to create a reactive reference whose .value is determined by a getter function.

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

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

// Computed property combines firstName and lastName
const fullName = computed(() => {
  console.log('Calculating fullName...'); // Will only log when firstName or lastName changes
  return `${firstName.value} ${lastName.value}`;
});

function changeName() {
    firstName.value = 'Jane';
}
</script>

<template>
  <div>
    <p>First Name: <input v-model="firstName"></p>
    <p>Last Name: <input v-model="lastName"></p>
    <p>Full Name: {{ fullName }}</p> <!-- Accessed like a ref -->
    <button @click="changeName">Change First Name</button>
  </div>
</template>

3.2 Writable Computed Properties

While less common, you can create a computed property that can also be set by providing both a getter and a setter.

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

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

const writableFullName = computed({
  // Getter
  get() {
    return `${firstName.value} ${lastName.value}`;
  },
  // Setter
  set(newValue) {
    // Assumes format "FirstName LastName"
    const names = newValue.split(' ');
    firstName.value = names[0] || '';
    lastName.value = names[1] || '';
  }
});
</script>

<template>
  <div>
     <p>Set Full Name: <input v-model="writableFullName"></p>
     <p>First Name: {{ firstName }}</p>
     <p>Last Name: {{ lastName }}</p>
  </div>
</template>

4. Watching State (watch & watchEffect)

Watchers allow you to perform side effects in response to changes in reactive state. There are two main functions: watch and watchEffect.

4.1 watch: Fine-Grained Control

watch requires explicitly specifying the reactive source(s) to watch. It provides the previous and current values and offers options like deep (for objects/arrays) and immediate (to run the callback immediately on creation).

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

const question = ref('');
const answer = ref('Questions usually contain a question mark. ;-)');

// Watch the 'question' ref
watch(question, async (newQuestion, oldQuestion) => {
  console.log(`Old: ${oldQuestion}, New: ${newQuestion}`);
  if (newQuestion.includes('?')) {
    answer.value = 'Thinking...';
    try {
      // Example API call (replace with actual fetch if needed)
      // const res = await fetch('https://yesno.wtf/api');
      // answer.value = (await res.json()).answer;
       await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API delay
       answer.value = Math.random() > 0.5 ? 'Yes!' : 'No!';
    } catch (error) {
      answer.value = 'Error! Could not reach the API.';
    }
  } else {
      answer.value = 'Questions usually contain a question mark. ;-)';
  }
});

// Watching reactive object properties requires a getter function
const user = reactive({ name: 'Alice', age: 30 });
watch(
  () => user.age, // Watch specific property
  (newAge, oldAge) => {
    console.log(`Age changed from ${oldAge} to ${newAge}`);
  }
);

// Use { deep: true } to watch all nested properties of an object/array
// watch(user, (newUser, oldUser) => { ... }, { deep: true });

</script>

<template>
  <p>
    Ask a yes/no question:
    <input v-model="question" />
  </p>
  <p>{{ answer }}</p>
</template>

4.2 watchEffect: Automatic Dependency Tracking

watchEffect runs a function immediately and automatically re-runs it whenever any of its reactive dependencies change. It's simpler for cases where you just need to react to *any* relevant change, without needing the old/new values or fine-grained control.

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

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

// Runs immediately, and re-runs whenever userId changes
watchEffect(async () => {
  console.log(`Workspaceing data for user ${userId.value}`);
  try {
      // Example: Simulate fetch based on userId
     await new Promise(resolve => setTimeout(resolve, 500));
     userData.value = { id: userId.value, name: `User ${userId.value}` };
     console.log('User data fetched:', userData.value);
  } catch(e) {
      console.error("Failed to fetch user data");
      userData.value = null;
  }
});

function loadNextUser() {
    userId.value++;
}
</script>

<template>
  <div>
      <p v-if="userData">Current User: {{ userData.name }} (ID: {{ userData.id }})</p>
      <p v-else>Loading user data...</p>
      <button @click="loadNextUser">Load Next User</button>
  </div>
</template>

5. Lifecycle Hooks within setup

Composition API provides functions for registering lifecycle hooks directly within the setup context (or <script setup>).

5.1 Mapping Options API Hooks

Most Options API lifecycle hooks have corresponding Composition API functions prefixed with on:

  • beforeCreate -> Not needed (use setup itself)
  • created -> Not needed (use setup itself)
  • beforeMount -> onBeforeMount
  • mounted -> onMounted
  • beforeUpdate -> onBeforeUpdate
  • updated -> onUpdated
  • beforeUnmount -> onBeforeUnmount
  • unmounted -> onUnmounted
  • errorCaptured -> onErrorCaptured
  • renderTracked -> onRenderTracked (Debug only)
  • renderTriggered -> onRenderTriggered (Debug only)

5.2 Usage Example

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

const timer = ref(0);
let intervalId = null;

console.log('Component setup initiated (like created/beforeCreate)');

onMounted(() => {
  console.log('Component has been mounted!');
  intervalId = setInterval(() => {
    timer.value++;
  }, 1000);
});

onUnmounted(() => {
  console.log('Component is about to be unmounted!');
  clearInterval(intervalId);
});
</script>

<template>
  <div>
    <p>Timer running since mount: {{ timer }} seconds</p>
  </div>
</template>

6. Reusable Logic with Composables

Composables are the primary pattern for extracting and reusing stateful logic in Vue 3's Composition API.

6.1 What are Composables?

A composable is essentially a function that leverages Composition API functions (ref, computed, watch, lifecycle hooks, etc.) to encapsulate state and logic. By convention, their names start with use (e.g., useMousePosition, useFetch). They can accept arguments and return reactive state, computed values, or methods.

6.2 Example: useMousePosition Composable

Let's create a composable function that tracks the mouse position.

Create a file (e.g., src/composables/useMousePosition.js):

// src/composables/useMousePosition.js
import { ref, onMounted, onUnmounted } from 'vue';

// Composable function
export function useMousePosition() {
  // State encapsulated and managed by the composable
  const x = ref(0);
  const y = ref(0);

  // Composable can encapsulate its own lifecycle logic
  function update(event) {
    x.value = event.pageX;
    y.value = event.pageY;
  }

  onMounted(() => {
    window.addEventListener('mousemove', update);
  });

  onUnmounted(() => {
    window.removeEventListener('mousemove', update);
  });

  // Expose managed state as return value
  return { x, y };
}

Now, use this composable in any component:

<!-- MyComponent.vue -->
<script setup>
import { useMousePosition } from '@/composables/useMousePosition'; // Adjust path as needed

// Use the composable to get reactive state
const { x, y } = useMousePosition();
</script>

<template>
  <div>
    Mouse position is at: {{ x }}, {{ y }}
  </div>
</template>

This pattern makes it incredibly easy to share complex, stateful logic across multiple components without mixins or complex inheritance structures.

7. Conclusion

Vue 3's Composition API, particularly with <script setup>, offers a powerful and elegant way to structure component logic. By mastering patterns around reactivity (ref, reactive), derived state (computed), side effects (watch, watchEffect), lifecycle management (onMounted, etc.), and logic reuse (composables), you can build more organized, maintainable, and scalable Vue applications. It's particularly beneficial for larger projects, teams, and those leveraging TypeScript.

8. Additional Resources

Related Articles

External Resources