DevToolBoxGRATIS
Blog

Vue Composition API Guide: Reactivity, Composables, Pinia, Vue Router & Performance Optimization

20 min readdi DevToolBox Team

The Vue 3 Composition API is the modern way to build Vue applications. It provides better TypeScript support, improved code reuse through composables, and more flexible component organization. This guide covers everything from reactive primitives to advanced patterns like Pinia, Vue Router 4, testing with Vitest, and performance optimization techniques that help you ship production-ready Vue applications.

TL;DR: The Composition API replaces Options API with function-based setup logic. Use ref() for primitives, reactive() for objects, computed() for derived state. Extract reusable logic into composables. Use Pinia for state management, Vue Router 4 for navigation, and Vitest for testing. Optimize with shallowRef, v-memo, and virtual scrolling.

Key Takeaways

  • Composition API enables better code organization by grouping related logic together instead of splitting across options.
  • Composables (use* functions) replace mixins as the primary code reuse pattern with full TypeScript support.
  • Pinia is the official state management solution, offering a simpler API than Vuex with full TypeScript inference.
  • Script setup with defineProps and defineEmits is the recommended syntax for single-file components.
  • Performance tools like shallowRef, v-memo, and computed caching prevent unnecessary re-renders.
  • Vue Router 4 composables (useRouter, useRoute) integrate seamlessly with the Composition API for navigation.
  • Teleport and Suspense are built-in components that solve common UI patterns without third-party libraries.

1. Composition API vs Options API

The Options API organizes code by option type (data, methods, computed, watch), while the Composition API groups code by logical concern. This makes large components easier to maintain and enables better code extraction. In a 500-line component, related logic for a single feature might be scattered across data, computed, methods, and watch options. The Composition API lets you keep all that logic together in a single function.

Both APIs have access to the same underlying Vue reactivity system. The Composition API does not deprecate the Options API. You can even use both in the same component during incremental migration. The key advantage is that composition functions can be extracted, tested, and shared independently.

In real-world applications, the Composition API shines when a component handles multiple concerns. For example, a dashboard component might manage user preferences, real-time data fetching, and chart rendering. With Options API, these three concerns would be interleaved across data, methods, and watchers. With Composition API, each concern lives in its own composable: usePreferences(), useRealtimeData(), and useChartRenderer().

// Options API (Vue 2 style)
export default {
  data() {
    return { count: 0, name: "Vue" };
  },
  computed: {
    double() { return this.count * 2; }
  },
  methods: {
    increment() { this.count++; }
  }
};

// Composition API (Vue 3)
import { ref, computed } from "vue";
const count = ref(0);
const name = ref("Vue");
const double = computed(() => count.value * 2);
const increment = () => count.value++;
Tip: Migration tip: You can use both APIs in the same component during migration. The Composition API setup() runs before Options API hooks, and they share the same component instance.

2. Reactive System: ref, reactive, computed, watch

Vue 3 provides multiple reactive primitives. Use ref() for primitive values (strings, numbers, booleans) and reactive() for objects. computed() creates cached derived state that only recalculates when dependencies change, while watch() and watchEffect() handle side effects when reactive data changes.

The key difference between watch and watchEffect is dependency tracking. watch() requires you to specify the source explicitly and provides both old and new values. watchEffect() automatically tracks all reactive dependencies used inside its callback, which is simpler but less precise.

toRef() and toRefs() are essential utilities for destructuring reactive objects without losing reactivity. toRef creates a ref linked to a specific property of a reactive object, while toRefs converts all properties to individual refs. This is particularly useful when returning state from composables.

import { ref, reactive, computed,
  watch, watchEffect } from "vue";

// ref: wraps primitives (access via .value)
const count = ref(0);
count.value++; // 1

// reactive: deep reactive object (no .value)
const state = reactive({ items: [], loading: false });
state.loading = true;

// computed: cached derived state
const total = computed(() => state.items.length);

// watch: react to specific source changes
watch(count, (newVal, oldVal) => {
  console.log(`Changed: \${oldVal} -> \${newVal}`);
});

// watchEffect: auto-tracks dependencies
watchEffect(() => {
  console.log("Count:", count.value);
});
Tip: Best practice: Prefer ref() over reactive() for top-level state. ref() works with primitives and objects, and its .value convention makes reactivity explicit. Use reactive() for local objects that you never need to reassign.

3. Composables: Reusable Logic

Composables are functions prefixed with "use" that encapsulate and reuse stateful logic using the Composition API. They replace mixins from Vue 2 with a cleaner, type-safe pattern that avoids naming conflicts. Each composable is a standalone function with explicit inputs and outputs.

Common composable patterns include useFetch for data fetching, useLocalStorage for persisted state, useEventListener for DOM events, useMediaQuery for responsive design, and useIntersectionObserver for visibility detection. The VueUse library provides over 200 production-ready composables.

When building composables, follow the cleanup pattern: if your composable sets up event listeners, intervals, or subscriptions, use onUnmounted to clean them up. Accept ref or getter arguments for reactive inputs using toValue() from Vue 3.3, which handles both ref values and getter functions uniformly.

// composables/useFetch.ts
import { ref, watchEffect } from "vue";

export function useFetch<T>(url: string) {
  const data = ref<T | null>(null);
  const error = ref<string | null>(null);
  const loading = ref(true);

  watchEffect(async () => {
    loading.value = true;
    try {
      const res = await fetch(url);
      data.value = await res.json();
    } catch (e: any) {
      error.value = e.message;
    } finally {
      loading.value = false;
    }
  });
  return { data, error, loading };
}
Tip: Convention: Always prefix composable names with "use" (useFetch, useAuth, useLocalStorage). Return reactive refs and functions as a plain object, never a reactive wrapper. Composables can call other composables to compose complex behavior.

4. Props & Emits with defineProps and defineEmits

Script setup provides compiler macros defineProps and defineEmits for type-safe component communication. These macros are compiled away at build time and do not need to be imported. withDefaults sets default prop values while maintaining full type inference.

Vue 3.3 introduced the defineModel macro for two-way binding, simplifying the v-model pattern. Instead of manually declaring a prop and emit, defineModel creates a ref that automatically syncs with the parent v-model value.

For complex components with many props, consider grouping related props into an interface and importing it. This keeps prop definitions clean and reusable across multiple components. The defineSlots macro provides type-safe scoped slots, enabling full TypeScript checking for slot content.

<script setup lang="ts">
// defineProps with TypeScript types
interface Props {
  title: string;
  count?: number;
  items: string[];
}
const props = withDefaults(defineProps<Props>(), {
  count: 0,
  items: () => [],
});

// defineEmits with typed events
const emit = defineEmits<{
  update: [value: string];
  delete: [id: number];
  submit: [];
}>(); 

emit("update", "new value");
emit("delete", 42);
</script>
Tip: Type-only props: defineProps supports both runtime declaration and type-only declaration. The type-only approach provides better TypeScript integration and is recommended for TypeScript projects.

5. Provide/Inject: Dependency Injection

Provide and inject enable passing data through the component tree without prop drilling. With TypeScript, you can use InjectionKey for full type safety across the provider and consumer. This pattern is especially useful for plugins, theme systems, and shared services.

Unlike props, provided values are not reactive by default. To make them reactive, provide a ref or reactive object. The injecting component will then automatically track changes. You can also provide functions to allow child components to update shared state.

A common pattern is creating a useProvide/useInject pair for each context. The provider composable calls provide() and the consumer composable calls inject() with proper error handling. This encapsulates the injection key and types, making the API clean for consumers who just call useTheme() or useAuth().

// types/injection-keys.ts
import type { InjectionKey, Ref } from "vue";

export interface UserContext {
  name: Ref<string>;
  logout: () => void;
}
export const UserKey: InjectionKey<UserContext>
  = Symbol("UserContext");

// ParentComponent.vue
import { provide, ref } from "vue";
import { UserKey } from "./types/injection-keys";
const name = ref("Alice");
provide(UserKey, {
  name,
  logout: () => { /* clear session */ }
});

// ChildComponent.vue (any depth)
import { inject } from "vue";
import { UserKey } from "./types/injection-keys";
const user = inject(UserKey); // fully typed
Tip: Safety tip: Always provide a default value or handle the undefined case when using inject(). Use InjectionKey<T> from vue to ensure the provided and injected types match at compile time.

6. Vue Router 4: Composition API Integration

Vue Router 4 provides composable functions like useRouter() and useRoute() for navigation and route access inside setup(). Navigation guards can be defined per-component using onBeforeRouteLeave and onBeforeRouteUpdate.

Route-level code splitting with lazy loading is essential for performance. Use dynamic imports in your route definitions to split each page into a separate chunk. Combined with prefetching, this ensures fast initial load while keeping navigation instant.

Vue Router 4 supports typed route definitions with the RouteRecordRaw interface. Define your routes in a separate file for better organization and use route meta fields for authentication guards, breadcrumbs, or page titles. The beforeEach global guard checks meta.requiresAuth to protect routes.

import { useRouter, useRoute,
  onBeforeRouteLeave } from "vue-router";

const router = useRouter();
const route = useRoute();

// Programmatic navigation
router.push({ name: "user", params: { id: "123" } });
router.replace("/dashboard");

// Access route params reactively
watch(() => route.params.id, (newId) => {
  fetchUser(newId as string);
});

// Per-component navigation guard
onBeforeRouteLeave((to, from) => {
  if (hasUnsavedChanges.value) {
    return confirm("Discard unsaved changes?");
  }
});
Tip: Performance tip: Use defineAsyncComponent with dynamic imports for route-level code splitting. This loads each route component only when the user navigates to it, significantly reducing the initial bundle size.

7. Pinia State Management

Pinia is the official Vue state management library, replacing Vuex. It offers a simple API with defineStore, supports both options and setup syntax, and provides full TypeScript type inference without extra configuration.

Pinia stores are automatically tree-shakeable, meaning unused stores are excluded from the production bundle. Stores can subscribe to each other, support plugins for persistence or logging, and integrate with Vue DevTools for time-travel debugging.

Pinia supports server-side rendering out of the box. In SSR applications, each request gets its own Pinia instance to avoid state leaking between users. Use the storeToRefs() helper to destructure store state while maintaining reactivity, similar to toRefs() for reactive objects.

import { defineStore } from "pinia";
import { ref, computed } from "vue";

// Setup store syntax (recommended)
export const useCartStore = defineStore("cart", () => {
  const items = ref<CartItem[]>([]);

  const total = computed(() =>
    items.value.reduce((s, i) => s + i.price * i.qty, 0)
  );

  function addItem(product: Product) {
    const existing = items.value.find(
      i => i.id === product.id
    );
    if (existing) existing.qty++;
    else items.value.push({ ...product, qty: 1 });
  }

  function clear() { items.value = []; }

  return { items, total, addItem, clear };
});
Tip: Architecture tip: Keep stores small and focused on a single domain (useCartStore, useAuthStore). Use the setup store syntax for better TypeScript inference and to leverage the full Composition API inside your stores.

8. Script Setup: defineExpose, defineOptions, Top-Level Await

Script setup is syntactic sugar that compiles the entire script block into the setup() function. Variables, functions, and imports declared at the top level are automatically available in the template without explicit return statements.

defineExpose is critical when using template refs to call child component methods. Without it, nothing in the script setup is accessible to the parent. defineOptions sets component-level options like name and inheritAttrs that cannot be declared in script setup otherwise.

When migrating from the Options API, note that script setup components no longer have a this context. Instead of this.$refs, use template ref variables. Instead of this.$emit, use the emit function from defineEmits. The Vue DevTools fully support script setup components with the same inspection capabilities.

<script setup lang="ts">
import { ref } from "vue";

// defineOptions: set component name, inheritAttrs
defineOptions({
  name: "MyDialog",
  inheritAttrs: false
});

// Component logic
const visible = ref(false);
const open = () => (visible.value = true);
const close = () => (visible.value = false);

// defineExpose: expose methods to parent via ref
defineExpose({ open, close });

// Top-level await (needs Suspense parent)
const config = await fetch("/api/config")
  .then(r => r.json());
</script>
Tip: Important: Top-level await requires a Suspense boundary in the parent component. The component will not render until the awaited promise resolves, and Suspense will display the fallback content during loading.

9. Teleport & Suspense

Teleport renders its slot content to a different DOM location specified by a CSS selector, solving the common z-index and overflow issues with modals and tooltips. Suspense handles async dependencies in the component tree with built-in loading states.

Suspense can handle multiple async dependencies at once. If a component tree has three async children, Suspense waits for all of them before switching from fallback to default content. You can also nest Suspense boundaries for more granular loading states.

Teleport supports a disabled prop that conditionally keeps the content in its original location. This is useful for responsive designs where a modal should only teleport on larger screens. You can also teleport to multiple targets by using multiple Teleport components with different to props.

<!-- Teleport: render modal at document body -->
<template>
  <button @click="showModal = true">Open</button>
  <Teleport to="body">
    <div v-if="showModal" class="modal-overlay">
      <div class="modal-content">
        <slot />
        <button @click="showModal = false">
          Close
        </button>
      </div>
    </div>
  </Teleport>
</template>

<!-- Suspense: async component with fallback -->
<Suspense>
  <template #default><AsyncDashboard /></template>
  <template #fallback>
    <div>Loading dashboard...</div>
  </template>
</Suspense>
Tip: Pattern: Combine Teleport with Transition for animated modals. The Teleport renders the modal at the body level, while Transition handles enter and leave animations seamlessly.

10. Custom Directives

Custom directives provide low-level DOM access when you need direct manipulation beyond what templates offer. In Vue 3, directives use lifecycle hooks that mirror component hooks: created, beforeMount, mounted, beforeUpdate, updated, beforeUnmount, and unmounted.

Directives receive three arguments: the DOM element, a binding object containing the value, argument, and modifiers, and the virtual node. For simple cases where you only need mounted and updated with the same behavior, use the shorthand function syntax.

In script setup, any camelCase variable starting with "v" is automatically available as a custom directive in the template. For example, declaring const vFocus = { mounted: el => el.focus() } makes v-focus available without any registration step. Global directives should be registered on the app instance.

// directives/vFocus.ts
import type { Directive } from "vue";

export const vFocus: Directive<HTMLInputElement> = {
  mounted(el) { el.focus(); }
};

// directives/vIntersection.ts
export const vIntersection: Directive<
  HTMLElement, () => void
> = {
  mounted(el, binding) {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) binding.value();
      },
      { threshold: 0.1 }
    );
    observer.observe(el);
    (el as any).__ob = observer;
  },
  unmounted(el) {
    (el as any).__ob?.disconnect();
  }
};
Tip: When to use: Prefer composables for most reusable logic. Use directives only when you need direct DOM manipulation that cannot be achieved through template bindings, such as focus management or intersection observation.

11. Transition & Animation

Vue provides built-in Transition and TransitionGroup components for applying CSS and JavaScript animations when elements enter, leave, or move within the DOM. They automatically apply CSS classes at specific stages of the transition lifecycle.

TransitionGroup renders an actual element (specified by the tag prop) and applies move transitions using FLIP animation technique. This enables smooth list reordering animations. JavaScript hooks (onBeforeEnter, onEnter, onLeave) allow integration with animation libraries like GSAP.

For complex multi-step animations, use the mode prop on Transition. The out-in mode waits for the current element to leave before the new one enters, preventing layout glitches. The in-out mode does the opposite. Without mode, both transitions happen simultaneously, which can cause visual overlap.

<!-- CSS Transition -->
<Transition name="fade">
  <p v-if="show">Hello</p>
</Transition>

<style>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

<!-- TransitionGroup for lists -->
<TransitionGroup name="list" tag="ul">
  <li v-for="item in items" :key="item.id">
    {{ item.text }}
  </li>
</TransitionGroup>
Tip: Performance tip: Use CSS transitions over JavaScript hooks when possible. For TransitionGroup list animations, add the move class for smooth FLIP animations. Set appear prop to animate on initial render.

12. Testing with Vitest

Vitest is the recommended testing framework for Vue 3 projects, built on top of Vite for instant test execution. It integrates seamlessly with Vue Test Utils for component testing and supports snapshot testing, mocking, and code coverage.

For end-to-end testing, pair Vitest unit tests with Playwright or Cypress. Vitest handles component and composable testing, while E2E tools verify the full user journey. Use Vitest coverage reports to identify untested code paths in your application.

Use vi.mock() to mock API calls and external dependencies in your tests. Vitest supports both timer mocking (vi.useFakeTimers) for testing debounced composables and DOM environment simulation through happy-dom or jsdom. Always test both the happy path and error states.

import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import Counter from "./Counter.vue";

// Component test
describe("Counter", () => {
  it("increments on click", async () => {
    const w = mount(Counter);
    await w.find("button").trigger("click");
    expect(w.text()).toContain("1");
  });

  it("renders with props", () => {
    const w = mount(Counter, {
      props: { initial: 5 }
    });
    expect(w.text()).toContain("5");
  });
});
Tip: Testing strategy: Test composables as pure functions when they have no lifecycle dependencies. For composables that use onMounted or watch, mount them inside a simple wrapper component using Vue Test Utils.

13. Performance Optimization

Vue 3 provides several tools for optimizing rendering performance. shallowRef avoids deep reactivity tracking for large objects, computed caches expensive calculations and only re-evaluates when dependencies change, v-memo skips re-rendering list items when specified values stay the same.

For lists with thousands of items, implement virtual scrolling using libraries like vue-virtual-scroller. This renders only the visible items in the viewport, dramatically reducing DOM nodes and improving scroll performance. Combine with shallowRef for the list data to avoid deep reactivity overhead.

Another important optimization is using markRaw() for large objects that should never be reactive, such as third-party class instances, large constant datasets, or Web Worker references. This tells Vue to skip the reactivity proxy entirely, avoiding the overhead of deep observation on objects that will never change.

import { shallowRef, computed,
  defineAsyncComponent } from "vue";

// shallowRef: only triggers on reassignment
const bigList = shallowRef<Item[]>([]);
// This triggers reactivity:
bigList.value = [...bigList.value, newItem];
// This does NOT trigger:
bigList.value.push(newItem);

// computed: cached, only recalculates on change
const sorted = computed(() =>
  [...items.value].sort((a, b) =>
    a.name.localeCompare(b.name)
  )
);

// Lazy-load heavy components
const Chart = defineAsyncComponent(
  () => import("./HeavyChart.vue")
);

<!-- v-memo: skip re-render if unchanged -->
<div v-for="item in list" :key="item.id"
  v-memo="[item.id, item.selected]">
  {{ item.name }}
</div>
Tip: Profiling: Use Vue DevTools Performance tab to identify slow components. Look for components that re-render frequently without visible changes. These are candidates for shallowRef, v-memo, or computed optimization.

Summary: Building Modern Vue 3 Applications

The Composition API fundamentally changes how we organize Vue components. By grouping logic by feature rather than by option type, components become easier to read, maintain, and refactor. The key building blocks are ref and reactive for state, computed for derived values, and watch/watchEffect for side effects.

Composables are the cornerstone of code reuse in Vue 3. They provide a clean, type-safe alternative to mixins with explicit dependencies and return values. Combined with Pinia for global state and Vue Router 4 for navigation, the Composition API delivers a cohesive, scalable development experience.

For production applications, always consider performance from the start. Use shallowRef for large data structures, computed for expensive derivations, v-memo for list rendering optimization, and lazy loading for route-level code splitting. Test your components with Vitest and Vue Test Utils to maintain confidence as your application grows.

The Vue ecosystem in 2026 is mature and cohesive. With official tools like Pinia, Vue Router, Vitest, and VitePress all designed around the Composition API, you get a consistent developer experience from state management to documentation. Start with script setup, build composables for shared logic, and gradually adopt advanced patterns as your application demands them.

Frequently Asked Questions

𝕏 Twitterin LinkedIn
È stato utile?

Resta aggiornato

Ricevi consigli dev e nuovi strumenti ogni settimana.

Niente spam. Cancella quando vuoi.

Prova questi strumenti correlati

{ }JSON FormatterJSTypeScript to JavaScript.*Regex Tester

Articoli correlati

React Design Patterns Guide: Compound Components, Custom Hooks, HOC, Render Props & State Machines

Complete React design patterns guide covering compound components, render props, custom hooks, higher-order components, provider pattern, state machines, controlled vs uncontrolled, composition, observer pattern, error boundaries, and module patterns.

Advanced TypeScript Guide: Generics, Conditional Types, Mapped Types, Decorators, and Type Narrowing

Master advanced TypeScript patterns. Covers generic constraints, conditional types with infer, mapped types (Partial/Pick/Omit), template literal types, discriminated unions, utility types deep dive, decorators, module augmentation, type narrowing, covariance/contravariance, and satisfies operator.

Clean Code Guide: Naming Conventions, SOLID Principles, Code Smells, Refactoring & Best Practices

Comprehensive clean code guide covering naming conventions, function design, SOLID principles, DRY/KISS/YAGNI, code smells and refactoring, error handling patterns, testing, code review, design by contract, and clean architecture.