Mastering Vue 3 Composition API: Unlock Reactive Power and Reusable Logic
Mastering Vue 3 Composition API: Unlock Reactive Power and Reusable Logic
Vue.js has long been praised for its approachability and progressive nature, making it a joy for developers to build user interfaces. With the release of Vue 3, a significant evolution arrived: the Composition API. This powerful set of APIs dramatically changes how we structure, organize, and reuse logic within our Vue applications, addressing many of the challenges faced with the traditional Options API, especially in large-scale applications.
If you've ever found your Vue components growing into unmanageable behemoths, with related logic scattered across data, methods, computed, and watch options, then the Composition API is here to rescue you. It offers a more flexible and robust way to compose component logic, leading to cleaner, more maintainable, and highly reusable codebases.
Let's embark on a journey to demystify the Vue 3 Composition API and unleash its full potential.
What is the Composition API and Why Do We Need It?
Before Vue 3, the standard way to define component logic was through the Options API. You would define properties like data, methods, computed, watch, and lifecycle hooks as separate options in a component object. While intuitive for smaller components, this approach often led to:
- Scattered Logic: When a component grew, logic related to a single feature (e.g., fetching data, form validation) would be spread across different options, making it hard to read and understand.
- Poor Reusability: Extracting and reusing stateful logic across multiple components was cumbersome, often relying on mixins which came with their own set of problems (e.g., name collision, unclear origin of properties).
- Type Inference Challenges: For TypeScript users, the Options API could sometimes make type inference more challenging.
The Composition API provides an alternative way to author Vue components. Instead of declaring options, you define component logic using imported functions, primarily within a new setup() function (or directly in <script setup>). This allows you to:
- Organize by Feature: Group related logic together, regardless of whether it's reactive state, methods, or lifecycle hooks.
- Enhance Reusability: Easily extract and reuse stateful logic across components through 'composables' – plain JavaScript functions.
- Improve Readability: Long components become easier to navigate as feature-specific logic is co-located.
- Better TypeScript Support: Its function-based nature provides superior type inference.
It's important to note that the Composition API is an additive feature. You can still use the Options API, and you can even mix both within the same application, giving you flexibility during migration or when starting new projects.
The setup() Function: Your Component's New Entry Point
The heart of the Composition API (when not using <script setup>) is the setup() function. This function executes before the component is created, once the props are resolved, and acts as the entry point for using Composition API features.
Inside setup(), you define your component's reactive state, computed properties, methods, watchers, and even register lifecycle hooks. Whatever you return from setup() (an object or a render function) will be exposed to the component's template and this context (if using Options API).
When using <script setup> (the recommended way), you no longer explicitly define a setup() function. Instead, anything declared at the top-level of <script setup> is automatically exposed to the template and compiled into the setup() function's return value.
Basic Structure with <script setup>
<script setup>
import { ref, computed, onMounted } from 'vue';
// Reactive state
const count = ref(0);
// Computed property
const doubledCount = computed(() => count.value * 2);
// Method
function increment() {
count.value++;
}
// Lifecycle hook
onMounted(() => {
console.log('Component is mounted!');
});
</script>
<template>
<div>
<p>Count: {{ count }}</p>
<p>Doubled Count: {{ doubledCount }}</p>
<button @click="increment">Increment</button>
</div>
</template>
This simple example already demonstrates how data, methods, and lifecycle hooks can be declared together, leading to a more cohesive block of logic.
Reactivity Fundamentals: ref(), reactive(), and toRefs()
In Vue, reactivity is key. When data changes, the UI should update automatically. The Composition API provides new ways to declare reactive state.
ref(): For Primitives and More
ref() is a function that takes an inner value and returns a reactive and mutable ref object. The ref object has a single property .value which points to the inner value. When you access or modify .value, Vue tracks these operations and triggers reactive updates.
ref() can hold any value type, including primitives (strings, numbers, booleans) and objects. When ref() holds an object, it automatically makes that object deeply reactive using reactive() internally.
<script setup>
import { ref } from 'vue';
const message = ref('Hello Vue 3!'); // Reactive string
const count = ref(0); // Reactive number
const isActive = ref(true); // Reactive boolean
// When accessing/mutating within <script setup> or JavaScript
console.log(message.value); // 'Hello Vue 3!'
message.value = 'New message';
// In the template, .value is automatically unwrapped for convenience
</script>
<template>
<div>
<p>{{ message }}</p>
<button @click="message = 'Updated!'">Update Message</button>
<p>Count: {{ count }}</p>
</div>
</template>
Key takeaway: Always access or modify a ref's value using .value in JavaScript. Vue unwraps it automatically in templates.
reactive(): For Objects (Deep Reactivity)
reactive() is designed specifically for creating reactive objects. It takes a plain JavaScript object and returns a reactive proxy of that object. All nested properties within the object also become reactive.
<script setup>
import { reactive } from 'vue';
const user = reactive({
name: 'Alice',
age: 30,
address: {
city: 'New York',
zip: '10001'
}
});
function celebrateBirthday() {
user.age++;
user.address.city = 'Los Angeles'; // Deeply reactive
}
</script>
<template>
<div>
<p>Name: {{ user.name }}</p>
<p>Age: {{ user.age }}</p>
<p>City: {{ user.address.city }}</p>
<button @click="celebrateBirthday">Happy Birthday!</button>
</div>
</template>
Important considerations with reactive():
reactive()works only for object types (objects, arrays, Maps, Sets). If you assign a primitive value to areactiveproperty directly, it will lose reactivity. Useref()for primitives.- When you destructure a reactive object, the destructured variables lose reactivity. To maintain reactivity when destructuring, you need
toRefs().
toRefs(): Preserving Reactivity During Destructuring
toRefs() converts a reactive object into a plain object where each property is a ref pointing to the original object's property. This is particularly useful when returning reactive state from a composable function, allowing components to destructure the returned object without losing reactivity.
<script setup>
import { reactive, toRefs } from 'vue';
const state = reactive({
firstName: 'John',
lastName: 'Doe'
});
// If we destructure directly, firstName and lastName would not be reactive
// const { firstName, lastName } = state; // WRONG for reactivity
// Using toRefs() to maintain reactivity
const { firstName, lastName } = toRefs(state);
function updateName() {
firstName.value = 'Jane'; // Mutate the ref's value
lastName.value = 'Smith';
}
</script>
<template>
<div>
<p>Full Name: {{ firstName }} {{ lastName }}</p>
<button @click="updateName">Change Name</button>
</div>
</template>
Notice that after using toRefs(), firstName and lastName become refs, so we access their values with .value in JavaScript, but still directly in the template.
Computed Properties: computed()
Just like in the Options API, computed() allows you to create derived reactive state. A computed property automatically updates when its dependencies change. It takes a getter function and returns a readonly ref object.
<script setup>
import { ref, computed } from 'vue';
const price = ref(10);
const quantity = ref(2);
// A readonly computed ref
const totalPrice = computed(() => price.value * quantity.value);
// A writable computed ref (with getter and setter)
const discountPrice = computed({
get: () => totalPrice.value * 0.9, // 10% discount
set: (newValue) => {
// We could adjust price or quantity based on new discountPrice
price.value = newValue / quantity.value / 0.9;
}
});
function applyDiscount() {
discountPrice.value = 18; // Calls the setter
}
</script>
<template>
<div>
<p>Price: ${{ price }}</p>
<p>Quantity: {{ quantity }}</p>
<p>Total Price: ${{ totalPrice }}</p>
<p>Discount Price (10% off): ${{ discountPrice }}</p>
<button @click="price++">Increase Price</button>
<button @click="quantity++">Increase Quantity</button>
<button @click="applyDiscount">Set Discount Price to $18</button>
</div>
</template>
Computed properties are excellent for complex transformations or aggregations of reactive data that you want to cache based on their dependencies.
Watchers: watch() and watchEffect()
Watchers allow you to perform side effects in response to changes in reactive state. The Composition API provides two main watcher functions: watch() and watchEffect().
watch(): Explicitly Define Dependencies
watch() is similar to the watch option in the Options API. It takes one or more reactive sources (a ref, a reactive object, a getter function, or an array of these) and a callback function that runs when the source changes.
<script setup>
import { ref, watch } from 'vue';
const question = ref('');
const answer = ref('I am thinking...');
// Watch a single ref
watch(question, async (newQuestion, oldQuestion) => {
if (newQuestion.includes('?')) {
answer.value = 'Thinking...';
try {
const response = await fetch('https://yesno.wtf/api');
const data = await response.json();
answer.value = data.answer + '.';
} catch (error) {
answer.value = 'Error! Could not reach the API. ' + error;
}
} else {
answer.value = 'Please ask a question ending with a question mark (?)';
}
});
// Watch multiple sources (array)
const firstName = ref('John');
const lastName = ref('Doe');
watch([firstName, lastName], ([newFirstName, newLastName], [oldFirstName, oldLastName]) => {
console.log(`Name changed from ${oldFirstName} ${oldLastName} to ${newFirstName} ${newLastName}`);
});
// Watching a reactive object (deeply by default)
const user = reactive({
name: 'Alice',
age: 30
});
watch(() => user.age, (newAge, oldAge) => {
console.log(`User age changed from ${oldAge} to ${newAge}`);
}, { deep: true }); // 'deep: true' is often unnecessary when watching a ref/getter of a reactive object, but useful for nested properties directly
// For watching the entire reactive object's properties (deeply)
watch(user, (newUser, oldUser) => {
console.log('User object changed:', newUser.name, newUser.age);
}, { deep: true });
</script>
<template>
<div>
<p>Ask a yes/no question:
<input v-model="question" />
</p>
<p>{{ answer }}</p>
<hr/>
<p>First Name: <input v-model="firstName" /></p>
<p>Last Name: <input v-model="lastName" /></p>
<hr/>
<p>User Name: {{ user.name }}</p>
<p>User Age: <input type="number" v-model="user.age" /></p>
</div>
</template>
watch() gives you fine-grained control over what to watch and also provides access to both newValue and oldValue.
watchEffect(): Automatic Dependency Tracking
watchEffect() is simpler. It takes a function and immediately runs it, automatically tracking any reactive dependencies accessed during its execution. When any of those dependencies change, the function runs again.
watchEffect() is useful when you want to run a side effect based on a group of reactive state, and you don't need access to the old value or specific control over which dependency triggers it.
<script setup>
import { ref, watchEffect } from 'vue';
const todoId = ref(1);
const todoData = ref(null);
watchEffect(async (onCleanup) => {
todoData.value = null; // Reset data on ID change
const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${todoId.value}`);
todoData.value = await response.json();
// Cleanup mechanism for watchEffect
onCleanup(() => {
console.log(`Cleanup for todoId: ${todoId.value}. If a new todoId is set before fetch completes, this runs.`);
// E.g., cancel a pending API request
});
});
function nextTodo() {
todoId.value++;
}
</script>
<template>
<div>
<p>Todo ID: {{ todoId }}</p>
<button @click="nextTodo">Next Todo</button>
<div v-if="todoData">
<h3>{{ todoData.title }}</h3>
<p>Completed: {{ todoData.completed ? 'Yes' : 'No' }}</p>
</div>
<div v-else>Loading todo...</div>
</div>
</template>
The onCleanup function passed to watchEffect's callback is useful for cleaning up side effects (e.g., canceling subscriptions, debouncing network requests) before the effect re-runs or the component unmounts.
Lifecycle Hooks in Composition API
In the Options API, lifecycle hooks are properties (e.g., mounted(), created()). With the Composition API, they are exposed as functions prefixed with on and should be called synchronously inside setup() (or <script setup>).
Here's a mapping of common Options API hooks to Composition API:
beforeCreate-> (Logic directly insetup()runs beforebeforeCreate)created-> (Logic directly insetup()runs beforecreated)beforeMount->onBeforeMount()mounted->onMounted()beforeUpdate->onBeforeUpdate()updated->onUpdated()beforeUnmount->onBeforeUnmount()unmounted->onUnmounted()errorCaptured->onErrorCaptured()renderTracked->onRenderTracked()renderTriggered->onRenderTriggered()activated(for<KeepAlive>) ->onActivated()deactivated(for<KeepAlive>) ->onDeactivated()
<script setup>
import { ref, onMounted, onUpdated, onUnmounted } from 'vue';
const count = ref(0);
onMounted(() => {
console.log('Component Mounted (Composition API)');
});
onUpdated(() => {
console.log('Component Updated (Composition API). Count is now:', count.value);
});
onUnmounted(() => {
console.log('Component Unmounted (Composition API)');
});
console.log('Component setup logic executed (like created/beforeCreate)');
</script>
<template>
<div>
<p>Count: {{ count }}</p>
<button @click="count++">Increment</button>
</div>
</template>
Notice how the console.log directly in <script setup> runs first, similar to beforeCreate/created hooks.
The Power of Composables: Reusable Logic
This is where the Composition API truly shines. Composables are functions that encapsulate stateful logic and can be reused across components. They typically leverage Composition API functions (ref, reactive, computed, watch, lifecycle hooks) to manage state and side effects.
A composable function usually:
- Starts with the prefix
use(e.g.,useMouseTracker,useFormValidation). This is a convention to indicate it's a composable. - Takes arguments (optional) to customize its behavior.
- Returns reactive state or functions (or both) that the consuming component can use.
Example: useMouseTracker Composable
Let's create a composable that tracks the mouse position.
src/composables/useMouseTracker.js
// src/composables/useMouseTracker.js
import { ref, onMounted, onUnmounted } from 'vue';
export function useMouseTracker() {
const x = ref(0);
const y = ref(0);
function update(event) {
x.value = event.pageX;
y.value = event.pageY;
}
onMounted(() => window.addEventListener('mousemove', update));
onUnmounted(() => window.removeEventListener('mousemove', update));
return { x, y }; // Return reactive state
}
Now, any component can use this logic:
src/components/MousePositionDisplay.vue
<script setup>
import { useMouseTracker } from '@/composables/useMouseTracker';
const { x, y } = useMouseTracker();
</script>
<template>
<div style="border: 1px solid #ccc; padding: 20px; text-align: center;">
<h3>Mouse Position Tracker</h3>
<p>X: {{ x }}</p>
<p>Y: {{ y }}</p>
</div>
</template>
src/App.vue
<script setup>
import MousePositionDisplay from './components/MousePositionDisplay.vue';
</script>
<template>
<h1>App Component</h1>
<MousePositionDisplay />
<MousePositionDisplay /> <!-- Reuse it! Each instance gets its own state -->
</template>
Each component using useMouseTracker gets its own independent state for x and y. This is fundamentally different from mixins, where state could inadvertently be shared or overwritten.
Advanced Topics: provide() / inject() for Dependency Injection
When passing data deeply through a component tree, prop drilling can become tedious. The Composition API offers provide() and inject() functions for dependency injection, similar to React's Context API or Vue's Options API provide/inject but with better TypeScript support and more flexibility.
provide() allows a parent component (or any ancestor) to make data available to any of its descendants, regardless of how deep they are. inject() allows a descendant component to consume that provided data.
Example: Providing and Injecting a Theme
src/App.vue (Provider)
<script setup>
import { ref, provide } from 'vue';
import ChildComponent from './components/ChildComponent.vue';
const currentTheme = ref('light');
function toggleTheme() {
currentTheme.value = currentTheme.value === 'light' ? 'dark' : 'light';
}
// Provide the theme and the toggle function
provide('theme-key', currentTheme);
provide('toggle-theme-key', toggleTheme);
</script>
<template>
<div :class="currentTheme + '-theme'">
<h1>App Component</h1>
<button @click="toggleTheme">Toggle Theme (Currently: {{ currentTheme }})</button>
<ChildComponent />
</div>
</template>
<style>
.light-theme {
background-color: #fff;
color: #333;
}
.dark-theme {
background-color: #333;
color: #eee;
}
</style>
src/components/ChildComponent.vue (Consumer)
<script setup>
import { inject } from 'vue';
import GrandchildComponent from './GrandchildComponent.vue';
// Inject the provided values
const theme = inject('theme-key');
console.log('ChildComponent injected theme:', theme.value);
</script>
<template>
<div :class="theme + '-border'" style="padding: 15px; margin-top: 10px;">
<h2>Child Component</h2>
<p>Theme from App: {{ theme }}</p>
<GrandchildComponent />
</div>
</template>
<style scoped>
.light-border {
border: 1px solid blue;
}
.dark-border {
border: 1px solid yellow;
}
</style>
src/components/GrandchildComponent.vue (Deep Consumer)
<script setup>
import { inject } from 'vue';
// Inject the provided values
const theme = inject('theme-key', 'default-theme-if-not-found'); // Optional default value
const toggleTheme = inject('toggle-theme-key');
</script>
<template>
<div :class="theme + '-bg'" style="padding: 10px; margin-top: 10px;">
<h3>Grandchild Component</h3>
<p>Deeply injected theme: {{ theme }}</p>
<button @click="toggleTheme">Toggle Theme from Grandchild</button>
</div>
</template>
<style scoped>
.light-bg {
background-color: lightblue;
}
.dark-bg {
background-color: darkgray;
}
</style>
provide() and inject() work with reactive values, so when currentTheme changes in App.vue, theme in ChildComponent and GrandchildComponent will automatically update.
Best Practices and Tips for Composition API
- Use
<script setup>: It's the recommended and most ergonomic way to use the Composition API, reducing boilerplate and improving readability. - Organize Logic with Composables: Extract related logic into composable functions (e.g.,
useAuth,useCart,useForm). This is the biggest benefit of the Composition API for maintainability and reusability. - Consistent Naming: Follow the
useXxxconvention for composables. For reactive variables, consider suffixingrefs withRef(e.g.,countRef) orreactiveobjects withState(e.g.,userState) when you return them from a composable and destructure them, to remember that they need.valueif they arerefs. However, when usingtoRefs(), therefconvention is usually enough. - Prioritize
ref()for Primitives: Whilereactive()is powerful for objects,ref()is generally safer and more flexible for primitive values to avoid reactivity loss issues. - Be Mindful of Destructuring
reactive(): Remember thatreactive()objects lose reactivity when destructured. UsetoRefs()when you need to destructure and maintain reactivity. - Separate Concerns: Even within a single component's
<script setup>, you can group related logic blocks with comments or by placing them into smaller, self-contained functions that are then called from the main setup scope. - TypeScript Benefits: Embrace TypeScript with Composition API. It provides excellent type inference, making your codebase more robust.
watch()vs.watchEffect(): Usewatch()when you need access to the old value, need to explicitly watch specific sources, or want to perform async operations without immediate execution. UsewatchEffect()for simpler side effects where you just want to react to any change in its dependencies.- Avoid Over-Complication: While powerful, don't feel compelled to move all logic to composables if it's truly component-specific and simple. A healthy balance is key.
Composition API vs. Options API: When to Use Which?
The Composition API is not a replacement for the Options API; it's an alternative. Here's a quick guide:
- Options API: Ideal for smaller, simpler components where logic is minimal. Its option-based structure makes it easy to quickly grasp a component's capabilities at a glance.
- Composition API: Shines in larger, more complex components or applications where logic needs to be organized by feature, reused across multiple components, or when leveraging TypeScript heavily. It empowers you to build highly scalable and maintainable applications.
Many projects adopt a hybrid approach, using Options API for simple UI components and Composition API for more complex logic or global state management.
Conclusion
The Vue 3 Composition API represents a significant leap forward in how we design and build Vue applications. By providing a flexible, function-based approach to component logic, it solves common pain points related to code organization, reusability, and maintainability.
Embracing ref(), reactive(), computed(), watch(), watchEffect(), and especially composables will empower you to write cleaner, more understandable, and robust Vue code, making you a more efficient and effective developer. Start experimenting with it in your next project, and you'll quickly appreciate the elegance and power it brings to your development workflow. Happy coding!