MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Vue.js 3 Composition API: A Deep Dive

Master Vue 3 Composition API: reactive, ref, computed, watch, and lifecycle hooks.

Vue.jsComposition APIFrontendJavaScript

By MinhVo

Introduction

Vue.js 3 introduced the Composition API as a powerful alternative to the Options API that developers had been using since Vue 2. This new paradigm addresses several pain points of the Options API, particularly when it comes to organizing complex component logic, reusing code across components, and better TypeScript type inference. While the Options API remains fully supported and is still a great choice for simpler components, the Composition API unlocks new possibilities for building scalable Vue applications.

The Composition API draws inspiration from React Hooks but takes a distinctly Vue approach. Instead of re-rendering the entire component on every state change, Vue's reactivity system surgically updates only the parts of the DOM that depend on changed state. This means you get the organizational benefits of functional composition without the performance overhead of re-executing entire component functions.

In this deep dive, we'll explore every aspect of the Composition API, from the fundamental building blocks like ref and reactive to advanced patterns like composable functions, dependency injection, and integration with Vue Router and Pinia. By the end, you'll have a thorough understanding of when and how to use the Composition API to write cleaner, more maintainable Vue code.

Vue.js Composition API

Understanding the Composition API: Core Concepts

Why the Composition API Exists

The Options API organizes component logic by option type: data, computed, methods, watch, and lifecycle hooks. While intuitive for simple components, this organization scatters related logic across different options when components grow complex.

Consider a component that manages a data table with sorting, filtering, and pagination. With the Options API, the sorting logic might be split across data (sort state), computed (sorted data), and methods (sort handlers), with related watchers scattered elsewhere. The Composition API lets you group all sorting-related code together.

// Options API - related logic scattered
export default {
  data() {
    return {
      sortField: 'name',
      sortDirection: 'asc',
      filterText: '',
      currentPage: 1,
      pageSize: 10,
    };
  },
  computed: {
    sortedData() { /* sorting logic */ },
    filteredData() { /* filtering logic */ },
    paginatedData() { /* pagination logic */ },
  },
  methods: {
    handleSort() { /* sort handler */ },
    handleFilter() { /* filter handler */ },
    handlePageChange() { /* page handler */ },
  },
};
// Composition API - related logic grouped
import { useSorting } from './composables/useSorting';
import { useFiltering } from './composables/useFiltering';
import { usePagination } from './composables/usePagination';
 
export default defineComponent({
  setup() {
    const { sortedData, sortField, sortDirection, handleSort } = useSorting(data);
    const { filteredData, filterText, handleFilter } = useFiltering(data);
    const { paginatedData, currentPage, pageSize, handlePageChange } = usePagination(filteredData);
    
    return { paginatedData, sortField, sortDirection, handleSort, filterText, handleFilter, currentPage, pageSize, handlePageChange };
  },
});

The setup() Function

The setup() function is the entry point for the Composition API. It runs before the component instance is created, making this unavailable inside setup().

import { defineComponent, ref, computed, onMounted } from 'vue';
 
export default defineComponent({
  props: {
    userId: {
      type: String,
      required: true,
    },
  },
  
  emits: ['userLoaded', 'error'],
  
  setup(props, { emit, attrs, slots, expose }) {
    // Props are reactive and cannot be destructured directly
    const userId = computed(() => props.userId);
    
    // State
    const user = ref(null);
    const loading = ref(false);
    const error = ref(null);
    
    // Methods
    const fetchUser = async () => {
      loading.value = true;
      error.value = null;
      
      try {
        const response = await fetch(`/api/users/${userId.value}`);
        user.value = await response.json();
        emit('userLoaded', user.value);
      } catch (err) {
        error.value = err.message;
        emit('error', err);
      } finally {
        loading.value = false;
      }
    };
    
    // Lifecycle hooks
    onMounted(() => {
      fetchUser();
    });
    
    // Expose specific methods for parent access via template refs
    expose({
      refresh: fetchUser,
    });
    
    return {
      user,
      loading,
      error,
      refresh: fetchUser,
    };
  },
});

Vue 3 reactivity system

Core Reactivity: ref and reactive

ref() for Primitive Values

The ref() function creates a reactive reference to a value. It wraps the value in an object with a .value property:

import { ref, computed } from 'vue';
 
// Primitive values
const count = ref(0);
const message = ref('Hello Vue');
const isActive = ref(true);
const user = ref(null);
 
// Access and modify via .value
console.log(count.value); // 0
count.value++;
console.log(count.value); // 1
 
// In templates, refs are automatically unwrapped
// <template>
//   <p>{{ count }}</p>  <!-- No .value needed -->
//   <button @click="count++">Increment</button>
// </template>

reactive() for Objects

The reactive() function creates a deep reactive proxy of an object:

import { reactive, toRefs, toRef } from 'vue';
 
// Objects and arrays
const state = reactive({
  user: {
    name: 'John',
    email: 'john@example.com',
    preferences: {
      theme: 'dark',
      language: 'en',
    },
  },
  posts: [],
  loading: false,
});
 
// Direct property access (no .value needed)
state.loading = true;
state.user.name = 'Jane';
state.posts.push({ id: 1, title: 'Hello' });
 
// Deep reactivity works automatically
state.user.preferences.theme = 'light'; // Triggers reactivity
 
// Convert reactive properties to refs for destructuring
const { name, email } = toRefs(state.user);
const theme = toRef(state.user.preferences, 'theme');

When to Use ref vs reactive

// Use ref for:
// 1. Primitive values
const count = ref(0);
const message = ref('');
 
// 2. When you need to reassign the entire value
const items = ref<Item[]>([]);
items.value = newItems; // Works fine
 
// 3. When passing to composables (clear ownership)
function useCounter(initialValue: number) {
  const count = ref(initialValue);
  const increment = () => count.value++;
  return { count, increment };
}
 
// Use reactive for:
// 1. Complex objects that won't be reassigned
const formState = reactive({
  name: '',
  email: '',
  errors: {},
});
 
// 2. When you want to avoid .value
const coordinates = reactive({ x: 0, y: 0 });
coordinates.x = 10; // No .value needed
 
// WARNING: Don't destructure reactive objects (loses reactivity)
const { x, y } = coordinates; // x and y are NOT reactive!

Computed Properties

Computed properties derive values from reactive state and cache the result:

import { ref, computed } from 'vue';
 
const firstName = ref('John');
const lastName = ref('Doe');
 
// Read-only computed
const fullName = computed(() => {
  return `${firstName.value} ${lastName.value}`;
});
 
// Writable computed
const fullNameWritable = computed({
  get() {
    return `${firstName.value} ${lastName.value}`;
  },
  set(newValue: string) {
    const [first, ...rest] = newValue.split(' ');
    firstName.value = first;
    lastName.value = rest.join(' ');
  },
});
 
// Complex computed with caching
const items = ref([
  { name: 'Apple', price: 1.5, category: 'fruit' },
  { name: 'Banana', price: 0.5, category: 'fruit' },
  { name: 'Carrot', price: 0.8, category: 'vegetable' },
]);
 
const selectedCategory = ref('fruit');
 
const filteredItems = computed(() => {
  return items.value.filter(item => item.category === selectedCategory.value);
});
 
const totalPrice = computed(() => {
  return filteredItems.value.reduce((sum, item) => sum + item.price, 0);
});
 
const averagePrice = computed(() => {
  const filtered = filteredItems.value;
  if (filtered.length === 0) return 0;
  return totalPrice.value / filtered.length;
});

Watchers

Watchers let you perform side effects in response to reactive state changes:

import { ref, watch, watchEffect, watchPostEffect } from 'vue';
 
const searchQuery = ref('');
const searchResults = ref([]);
const isLoading = ref(false);
 
// watch - explicit watching with old/new values
watch(searchQuery, async (newQuery, oldQuery) => {
  if (newQuery === oldQuery) return;
  
  isLoading.value = true;
  try {
    const response = await fetch(`/api/search?q=${encodeURIComponent(newQuery)}`);
    searchResults.value = await response.json();
  } finally {
    isLoading.value = false;
  }
}, {
  immediate: false, // Don't run on initial render
  debounce: 300, // Wait 300ms after last change
});
 
// watchEffect - automatically tracks dependencies
watchEffect(async () => {
  const query = searchQuery.value; // Dependency tracked automatically
  
  if (!query) {
    searchResults.value = [];
    return;
  }
  
  isLoading.value = true;
  try {
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
    searchResults.value = await response.json();
  } finally {
    isLoading.value = false;
  }
});
 
// watchPostEffect - runs after DOM updates
watchPostEffect(() => {
  console.log('DOM has been updated');
  // Useful for DOM-dependent side effects
});
 
// Watching multiple sources
const firstName = ref('John');
const lastName = ref('Doe');
 
watch([firstName, lastName], ([newFirst, newLast], [oldFirst, oldLast]) => {
  console.log(`Name changed from ${oldFirst} ${oldLast} to ${newFirst} ${newLast}`);
});
 
// Stopping watchers
const stop = watch(searchQuery, (value) => {
  console.log('Query:', value);
});
 
// Later, stop watching
stop();

Composition API patterns

Lifecycle Hooks

The Composition API provides equivalents for all Options API lifecycle hooks:

import {
  onBeforeMount,
  onMounted,
  onBeforeUpdate,
  onUpdated,
  onBeforeUnmount,
  onUnmounted,
  onActivated,
  onDeactivated,
  onErrorCaptured,
} from 'vue';
 
export default defineComponent({
  setup() {
    const listRef = ref(null);
    
    onBeforeMount(() => {
      console.log('Component is about to mount');
    });
    
    onMounted(() => {
      console.log('Component mounted');
      // DOM is now available
      console.log(listRef.value); // Access template ref
      
      // Setup event listeners, subscriptions, etc.
      window.addEventListener('resize', handleResize);
    });
    
    onBeforeUpdate(() => {
      console.log('Component is about to update');
    });
    
    onUpdated(() => {
      console.log('Component updated');
      // DOM has been updated
    });
    
    onBeforeUnmount(() => {
      console.log('Component is about to unmount');
      // Cleanup before unmount
      window.removeEventListener('resize', handleResize);
    });
    
    onUnmounted(() => {
      console.log('Component unmounted');
      // Final cleanup
    });
    
    // Keep-alive lifecycle hooks
    onActivated(() => {
      console.log('Component activated');
    });
    
    onDeactivated(() => {
      console.log('Component deactivated');
    });
    
    // Error handling
    onErrorCaptured((err, instance, info) => {
      console.error('Error captured:', err);
      return false; // Prevent error from propagating
    });
    
    return { listRef };
  },
});

Building Composables

Composables are the Composition API's answer to mixins. They're functions that encapsulate reusable stateful logic:

// composables/useFetch.ts
import { ref, watchEffect, toValue, type Ref } from 'vue';
 
interface UseFetchOptions {
  immediate?: boolean;
  headers?: Record<string, string>;
}
 
interface UseFetchReturn<T> {
  data: Ref<T | null>;
  error: Ref<string | null>;
  loading: Ref<boolean>;
  execute: () => Promise<void>;
  abort: () => void;
}
 
export function useFetch<T>(
  url: string | Ref<string>,
  options: UseFetchOptions = {}
): UseFetchReturn<T> {
  const data = ref<T | null>(null) as Ref<T | null>;
  const error = ref<string | null>(null);
  const loading = ref(false);
  
  let controller: AbortController | null = null;
  
  const execute = async () => {
    // Abort previous request
    controller?.abort();
    controller = new AbortController();
    
    loading.value = true;
    error.value = null;
    
    try {
      const response = await fetch(toValue(url), {
        headers: options.headers,
        signal: controller.signal,
      });
      
      if (!response.ok) {
        throw new Error(`HTTP error: ${response.status}`);
      }
      
      data.value = await response.json();
    } catch (err) {
      if (err.name !== 'AbortError') {
        error.value = err.message;
      }
    } finally {
      loading.value = false;
    }
  };
  
  const abort = () => {
    controller?.abort();
  };
  
  // Auto-fetch when URL changes
  if (options.immediate !== false) {
    watchEffect(() => {
      toValue(url); // Track reactive URL
      execute();
    });
  }
  
  return { data, error, loading, execute, abort };
}
// composables/useLocalStorage.ts
import { ref, watch } from 'vue';
 
export function useLocalStorage<T>(key: string, defaultValue: T) {
  const stored = localStorage.getItem(key);
  const data = ref<T>(stored ? JSON.parse(stored) : defaultValue);
  
  watch(data, (newValue) => {
    localStorage.setItem(key, JSON.stringify(newValue));
  }, { deep: true });
  
  const remove = () => {
    localStorage.removeItem(key);
    data.value = defaultValue;
  };
  
  return { data, remove };
}
// composables/useIntersectionObserver.ts
import { ref, onMounted, onUnmounted, type Ref } from 'vue';
 
export function useIntersectionObserver(
  target: Ref<HTMLElement | null>,
  options: IntersectionObserverInit = {}
) {
  const isIntersecting = ref(false);
  const entry = ref<IntersectionObserverEntry | null>(null);
  
  let observer: IntersectionObserver | null = null;
  
  onMounted(() => {
    if (!target.value) return;
    
    observer = new IntersectionObserver(([entry]) => {
      isIntersecting.value = entry.isIntersecting;
      entry.value = entry;
    }, options);
    
    observer.observe(target.value);
  });
  
  onUnmounted(() => {
    observer?.disconnect();
  });
  
  return { isIntersecting, entry };
}

Advanced Patterns

Provide/Inject for Dependency Injection

// Parent component provides values
import { provide, ref, readonly } from 'vue';
 
// symbols.ts
export const ThemeSymbol = Symbol('theme');
export const UserSymbol = Symbol('user');
 
// ParentComponent.vue
export default defineComponent({
  setup() {
    const theme = ref('dark');
    const user = ref({ name: 'John', role: 'admin' });
    
    // Provide reactive values
    provide(ThemeSymbol, {
      theme: readonly(theme),
      toggleTheme: () => {
        theme.value = theme.value === 'dark' ? 'light' : 'dark';
      },
    });
    
    provide(UserSymbol, readonly(user));
  },
});
 
// Child component injects values
// ChildComponent.vue
export default defineComponent({
  setup() {
    const themeContext = inject(ThemeSymbol);
    const user = inject(UserSymbol);
    
    if (!themeContext) {
      throw new Error('ThemeContext not provided');
    }
    
    return { theme: themeContext.theme, toggleTheme: themeContext.toggleTheme, user };
  },
});

Template Refs with TypeScript

import { ref, onMounted } from 'vue';
 
export default defineComponent({
  setup() {
    const canvasRef = ref<HTMLCanvasElement | null>(null);
    const inputRef = ref<HTMLInputElement | null>(null);
    
    onMounted(() => {
      // Type-safe access to DOM elements
      if (canvasRef.value) {
        const ctx = canvasRef.value.getContext('2d');
        ctx?.fillRect(0, 0, 100, 100);
      }
      
      inputRef.value?.focus();
    });
    
    return { canvasRef, inputRef };
  },
});

Best Practices for Production

  1. Extract composables for reusable logic — If logic is used in multiple components, extract it into a composable function
  2. Use readonly() for provided values — Prevent child components from mutating parent state directly
  3. Type your refs explicitly — Use ref<Type>() for better TypeScript inference
  4. Avoid large setup() functions — Break complex components into smaller composables
  5. Use toRef() for props destructuring — Maintain reactivity when extracting individual props
  6. Clean up side effects — Remove event listeners and cancel subscriptions in onUnmounted
  7. Prefer watchEffect for automatic dependency tracking — Less verbose than watch when you don't need old values
  8. Use shallowRef for large objects — Avoid deep reactivity overhead when you only need top-level reactivity

Comparison with Options API

FeatureOptions APIComposition API
Code OrganizationBy option typeBy logical concern
ReusabilityMixins (naming conflicts)Composables (clean)
TypeScriptLimited inferenceFull type inference
Learning CurveLowerModerate
Bundle SizeSlightly smallerTree-shakeable
FlexibilityFixed structureFull control

Composition API vs Options API Decision Guide

Use the Composition API for new projects and complex components that benefit from logical colocation. The Options API is still fully supported and may be simpler for components with straightforward data, methods, and lifecycle hooks. Mix and match within a project — use <script setup> for complex composables and the Options API for simple presentational components. The Composition API's use prefix convention makes it easy to identify reusable logic. Extract shared logic into composables (custom hooks) that can be tested independently and shared across components.

Vue 3 Performance Optimization

Optimize Vue 3 Composition API components by using shallowRef and shallowReactive for large objects that don't need deep reactivity. Use v-memo directive for expensive list rendering to skip re-renders of unchanged items. Implement virtual scrolling for long lists using libraries like vue-virtual-scroller. Use defineAsyncComponent with loading and error components for code splitting. Profile component render performance using Vue DevTools Timeline to identify slow components and unnecessary re-renders.

Production Deployment and Operations

Running backend services in production requires attention to reliability, observability, and operational concerns that don't exist in development environments. Proper deployment practices ensure your service remains available and performant under real-world conditions.

Graceful Shutdown Handling

Implement graceful shutdown to prevent request failures during deployments and restarts:

const server = app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
 
async function gracefulShutdown(signal) {
  console.log(`Received ${signal}, starting graceful shutdown...`);
 
  // Stop accepting new connections
  server.close(async () => {
    console.log('HTTP server closed');
 
    try {
      // Wait for existing requests to complete (with timeout)
      await Promise.race([
        waitForActiveRequests(),
        new Promise((_, reject) =>
          setTimeout(() => reject(new Error('Shutdown timeout')), 30000)
        ),
      ]);
 
      // Close database connections
      await db.destroy();
      await redis.quit();
 
      console.log('Graceful shutdown completed');
      process.exit(0);
    } catch (error) {
      console.error('Error during shutdown:', error);
      process.exit(1);
    }
  });
 
  // Force shutdown after timeout
  setTimeout(() => {
    console.error('Forced shutdown after timeout');
    process.exit(1);
  }, 35000);
}
 
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

Structured Logging

Replace console.log with structured logging that supports log aggregation and querying:

const pino = require('pino');
 
const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  formatters: {
    level(label) {
      return { level: label };
    },
  },
  serializers: {
    err: pino.stdSerializers.err,
    req: pino.stdSerializers.req,
    res: pino.stdSerializers.res,
  },
  redact: {
    paths: ['req.headers.authorization', 'req.headers.cookie'],
    remove: true,
  },
});
 
// Request logging middleware
app.use((req, res, next) => {
  const start = Date.now();
  res.on('finish', () => {
    logger.info({
      req,
      res,
      responseTime: Date.now() - start,
    }, `${req.method} ${req.url} ${res.statusCode}`);
  });
  next();
});

Rate Limiting and Abuse Prevention

Protect your API endpoints with rate limiting that adapts to different client types:

const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
 
const apiLimiter = rateLimit({
  store: new RedisStore({ client: redisClient }),
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // 100 requests per window
  standardHeaders: true,
  legacyHeaders: false,
  keyGenerator: (req) => req.user?.id || req.ip,
  handler: (req, res) => {
    logger.warn({ ip: req.ip, user: req.user?.id }, 'Rate limit exceeded');
    res.status(429).json({
      error: 'Too many requests',
      retryAfter: Math.ceil(req.rateLimit.resetTime / 1000),
    });
  },
});
 
app.use('/api/', apiLimiter);

These operational practices form the foundation of a reliable production service that can handle real-world traffic patterns and failure scenarios.

Community Resources and Further Learning

The technology landscape evolves rapidly, making continuous learning essential for maintaining expertise. Building a systematic approach to staying current with developments in your technology stack ensures you can leverage new features and avoid deprecated patterns.

Curated Learning Pathways

Rather than consuming content randomly, create structured learning pathways aligned with your current projects and career goals. Start with official documentation and specification documents, which provide the most accurate and comprehensive information. Follow this with hands-on tutorials and workshops that reinforce concepts through practical application.

Technical blogs from framework maintainers and core team members often provide deeper insights into design decisions and upcoming features. Subscribe to the official blogs of your primary frameworks and libraries to stay ahead of breaking changes and deprecation timelines.

Contributing to Open Source

Contributing to open-source projects in your technology stack provides unparalleled learning opportunities. Start with documentation improvements and bug reports, then progress to fixing small issues tagged as "good first issue" in your favorite projects. This direct engagement with maintainers and the codebase accelerates your understanding far beyond what passive learning can achieve.

# Setting up for contribution
git clone https://github.com/project/repository.git
cd repository
git checkout -b fix/issue-description
 
# Run the project's contribution setup
npm run setup:dev
npm run test  # Ensure tests pass before making changes
 
# Make your changes, then run the full test suite
npm run test:full
npm run lint
npm run build
 
# Submit your contribution
git add -A
git commit -m "fix: description of the fix
 
Closes #1234"
git push origin fix/issue-description

Building a Technical Knowledge Base

Maintain a personal knowledge base that captures insights, solutions, and patterns you discover during your work. Tools like Obsidian, Notion, or even a simple Markdown repository can serve as an external memory that grows more valuable over time.

Organize your notes by topic rather than chronologically, and include code examples, links to relevant documentation, and explanations of why certain approaches work better than others. When you encounter a particularly insightful article or conference talk, write a summary that captures the key takeaways and how they apply to your current projects.

Follow key conferences and their published talks to stay informed about emerging patterns and best practices. Many conferences publish recorded talks on YouTube within weeks of the event, making world-class technical content freely accessible.

Join relevant Discord servers, Slack communities, and forums where practitioners discuss real-world challenges and solutions. These communities provide early warning about emerging issues and access to collective wisdom that isn't available through formal documentation.

Mentorship and Knowledge Sharing

Teaching others is one of the most effective ways to deepen your own understanding. Consider writing technical blog posts, giving talks at local meetups, or mentoring junior developers. The process of explaining concepts to others forces you to organize your knowledge and identify gaps in your understanding.

Pair programming sessions with colleagues of different experience levels create mutual learning opportunities. Senior developers gain fresh perspectives on problems they've solved the same way for years, while junior developers benefit from exposure to production-grade thinking and decision-making processes.

Conclusion

The Composition API represents a fundamental shift in how we write Vue components. By organizing code by logical concern rather than option type, it enables better code reuse through composables, superior TypeScript support, and more flexible component architectures. While the Options API remains a valid choice for simpler components, the Composition API is the recommended approach for building scalable, maintainable Vue applications. The key is to start small — refactor one complex component at a time, extract shared logic into composables, and gradually adopt the patterns that make sense for your project.