Skip to content
Vue.js Advanced Guide — Composition API, Pinia & More

Vue.js Advanced Guide — Composition API, Pinia & More

DodaTech Updated Jun 6, 2026 15 min read

Advanced Vue.js patterns like the Composition API, Pinia, and composables unlock cleaner code organization, reusable logic, and scalable application architecture.

What You’ll Learn

  • Write components with Composition API and <script setup>
  • Extract reusable logic into composables
  • Manage global state with Pinia (state, getters, actions)
  • Render content outside the component tree with Teleport
  • Handle async components with Suspense
  • Add transitions and animations
  • Create custom directives for DOM manipulation
  • Handle errors globally and per-component
  • Optimize performance with shallowRef, markRaw, and nextTick

Why Advanced Vue Matters

Basic Vue gets you started, but real-world apps need state management, reusable logic, and performance tuning. The Composition API replaces the Options API as the modern standard — it provides better TypeScript support, cleaner code organization, and composables for sharing logic without mixins.

At DodaTech, Durga Antivirus Pro uses Pinia to manage real-time threat data across multiple dashboard panels. DodaZIP uses composables to share file-scanning logic between the file browser and the search panel. Doda Browser’s settings page uses Teleport to render modals outside the component tree for proper stacking.

Your Learning Path

    flowchart LR
  A[Vue Router] --> B[Vue Advanced]
  B --> C[Full Vue Project]
  B:::current

  classDef current fill:#42b883,color:#fff,stroke:#3aa876,stroke-width:2px
  
Prerequisites: Complete https://tutorials.dodatech.com/frameworks/vue/vue-components/ and https://tutorials.dodatech.com/frameworks/vue/vue-router/. You should be comfortable with JavaScript ES6+ (arrow functions, destructuring, modules) and TypeScript basics help but aren’t required.

Options API vs Composition API — The Shift

The Options API organizes code by option type: all data here, all methods there, all computed here. For small components this is fine. But as a component grows, related logic gets scattered across different sections.

The Composition API organizes code by logical concern. If you have a search feature that needs data, a method, and a watcher, they sit together — not across three sections.

The spreadsheet analogy extends here: With the Options API, you declare spreadsheet cells in one place, formulas in another, and macros in a third. The Composition API lets you group related cells, formulas, and macros together — like keeping a budget section together rather than all formulas in one tab.

Composition API — <script setup>

Let’s walk through a complete example line by line:

<script setup>
// In <script setup>, imports are available directly in the template.
// No export default needed — everything declared here is automatically
// available in the template above.
import { ref, reactive, computed, watch, onMounted, onUnmounted } from "vue";

// ref() wraps any value (primitives and objects) in a reactive container.
// In <script>, you access the value with .value (count.value).
// In <template>, Vue auto-unwraps it — just use {{ count }}.
const count = ref(0);

// reactive() only works with objects. No .value needed — direct access.
const user = reactive({
  name: "Alice",
  age: 30
});

// computed() creates a derived value that caches until deps change.
// Note the .value on count — we're in <script> context.
const double = computed(() => count.value * 2);

// Methods are just functions — no methods: section needed.
function increment() {
  count.value++;    // .value is REQUIRED here (script), NOT in template
}

// watch() watches a reactive source and runs a callback.
// First arg: the thing to watch. Second: callback with new/old values.
watch(count, (newVal, oldVal) => {
  console.log(`Count changed from ${oldVal} to ${newVal}`);
});

// Lifecycle hooks use onX functions instead of X() methods.
// onMounted runs after the component is inserted into the DOM.
onMounted(() => {
  console.log("Component mounted!");
});

onUnmounted(() => {
  console.log("Cleanup timers and listeners here");
});
</script>

<!-- In templates, refs are auto-unwrapped  no .value needed.
     Compare: {{ count }} works, NOT {{ count.value }} -->
<template>
  <p>Count: {{ count }} (double: {{ double }})</p>
  <p>User: {{ user.name }}, {{ user.age }}</p>
  <button @click="increment">+1</button>
</template>

What changed from Options API:

Options APIComposition API
export default { data() { ... } }Just declare ref(), reactive()
methods: { fn() {} }Just declare function fn() {}
computed: { prop() {} }Just declare const prop = computed(...)
mounted() { ... }onMounted(() => { ... })
this.countcount.value (script) or count (template)

ref vs reactive — When to Use Which

import { ref, reactive } from "vue";

// ref — wraps ANY value, accessed via .value in script
const name = ref("Alice");
name.value = "Bob";              // Must use .value

// reactive — only accepts objects, direct property access
const state = reactive({ name: "Alice", todos: [] });
state.name = "Bob";              // No .value needed
state.todos.push({ id: 1, text: "Learn Vue" });

// ref also accepts objects — calls reactive() internally
const obj = ref({ count: 0 });
obj.value.count = 1;             // .value needed to access the object
Featurerefreactive
Primitives✅ Yes❌ No
Objects✅ Yes (wraps in reactive)✅ Yes
Reassign variable✅ Yes❌ Loses reactivity
.value neededYes (in script)No
TypeScriptExcellentGood
RecommendationStart hereUse for objects you never reassign
Rule of thumb: Start with ref for everything. Switch to reactive only when you have an object you never reassign and don’t want .value everywhere. This avoids the most common Composition API confusion.

Lifecycle Hooks

import {
  onBeforeMount, onMounted,
  onBeforeUpdate, onUpdated,
  onBeforeUnmount, onUnmounted,
  onActivated, onDeactivated,
  onErrorCaptured
} from "vue";

onMounted(() => { /* fetch data */ });
onUnmounted(() => { /* cleanup — remove event listeners, timers */ });
onErrorCaptured((err) => { console.error(err); return false; });

Composables — Reusable Logic

Composables are the Composition API’s answer to reusability — replacing mixins without the drawbacks. A composable is just a function that uses Vue’s Composition API and returns reactive state:

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

// A composable is just a function that returns reactive state + methods.
// It can accept parameters and return anything a component needs.
export function useCounter(initialValue = 0) {
  const count = ref(initialValue);
  const double = computed(() => count.value * 2);

  function increment() { count.value++; }
  function decrement() { count.value--; }
  function reset() { count.value = initialValue; }

  // Return what other components need — just reactive values and functions
  return { count, double, increment, decrement, reset };
}
// composables/useFetch.js — reusable data fetching
import { ref, onMounted } from "vue";

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

  async function fetchData() {
    loading.value = true;
    error.value = null;
    try {
      const res = await fetch(url);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      data.value = await res.json();
    } catch (err) {
      error.value = err.message;
    } finally {
      loading.value = false;
    }
  }

  onMounted(fetchData);

  // Return reactive state + refresh method
  return { data, error, loading, refresh: fetchData };
}
<!-- Using the composable  one line replaces 20+ lines -->
<script setup>
import { useFetch } from "./composables/useFetch";

// Just destructure what you need — clean and explicit
const { data, error, loading } = useFetch("https://jsonplaceholder.typicode.com/posts/1");
</script>

<template>
  <p v-if="loading">Loading...</p>
  <p v-else-if="error">{{ error }}</p>
  <div v-else>
    <h2>{{ data.title }}</h2>
    <p>{{ data.body }}</p>
  </div>
</template>

Composables vs mixins:

FeatureComposablesMixins
Name conflicts❌ None — explicit destructuring✅ Possible — property collision
Source of propertiesClear (import + return)Unclear — where did that come from?
Tree-shakable✅ Yes❌ No — entire mixin must be included
TypeScript✅ Excellent❌ Limited — hard to type merged properties

Pinia — State Management

Pinia is the official Vue store, replacing Vuex. Think of it as a global reactive object accessible from any component — like a shared filing cabinet everyone can access:

Setup

npm install pinia
// src/main.js
import { createPinia } from "pinia";
const pinia = createPinia();
app.use(pinia);

Defining a Store

// stores/cart.js — a shopping cart store
import { defineStore } from "pinia";
import { ref, computed } from "vue";

// defineStore returns a hook (useCartStore) that creates/retrieves the store.
// The first argument is a unique store ID — used by Pinia DevTools.
export const useCartStore = defineStore("cart", () => {
  // State — ref() and reactive() like any composable
  const items = ref([]);
  const discountCode = ref(null);

  // Getters — computed() for derived state
  const itemCount = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));
  const total = computed(() => {
    let t = items.value.reduce((sum, i) => sum + i.price * i.qty, 0);
    if (discountCode.value === "SAVE10") t *= 0.9;
    return t;
  });

  // Actions — methods that mutate state
  function addItem(product) {
    const existing = items.value.find(i => i.id === product.id);
    if (existing) {
      existing.qty++;
    } else {
      items.value.push({ ...product, qty: 1 });
    }
  }

  function removeItem(id) {
    items.value = items.value.filter(i => i.id !== id);
  }

  function applyDiscount(code) {
    discountCode.value = code;
  }

  // Return everything the store exposes
  return { items, discountCode, itemCount, total, addItem, removeItem, applyDiscount };
});

Using the Store in a Component

<script setup>
import { useCartStore } from "../stores/cart";

// Call the hook — returns the store instance (singleton).
// All components share the SAME store instance.
const cart = useCartStore();

function checkout() {
  console.log("Checkout:", cart.items, "Total:", cart.total);
}
</script>

<template>
  <div>
    <p>Items: {{ cart.itemCount }}</p>
    <p>Total: ${{ cart.total.toFixed(2) }}</p>
    <ul>
      <li v-for="item in cart.items" :key="item.id">
        {{ item.name }} × {{ item.qty }}  ${{ (item.price * item.qty).toFixed(2) }}
        <button @click="cart.removeItem(item.id)"></button>
      </li>
    </ul>
    <input v-model="code" placeholder="Discount code" />
    <button @click="cart.applyDiscount(code)">Apply</button>
  </div>
</template>

Pinia vs component state — when to use what:

State typeStore whenExample
LocalOnly one component needs itForm input state, dropdown toggle
SharedMultiple components need itUser auth, shopping cart, theme
GlobalEvery component needs itApp config, notifications, language

Teleport — Render Elsewhere in the DOM

Teleport renders content to a different DOM node while preserving the Vue component hierarchy. Perfect for modals that need to escape overflow: hidden parents:

<template>
  <button @click="open = true">Open Modal</button>

  <!-- Teleport sends content to document.body.
       The modal will be a direct child of <body>  no CSS clipping issues.
       Even though it renders elsewhere, it still has access to component
       state and events. -->
  <Teleport to="body">
    <div v-if="open" style="position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center;">
      <div style="background: white; padding: 24px; border-radius: 8px;">
        <h2>Modal Content</h2>
        <button @click="open = false">Close</button>
      </div>
    </div>
  </Teleport>
</template>

<script setup>
import { ref } from "vue";
const open = ref(false);
</script>

Suspense — Async Components

Suspense handles async dependencies with a fallback UI — like a “loading…” placeholder while data is being fetched:

<template>
  <Suspense>
    <!-- This component has async setup()  Suspense waits for it -->
    <AsyncDashboard />
    <template #fallback>
      <div>Loading dashboard...</div>    <!-- Shown while async ops complete -->
    </template>
  </Suspense>
</template>

<script setup>
import { defineAsyncComponent } from "vue";

// defineAsyncComponent lazily loads a component from a separate file chunk
const AsyncDashboard = defineAsyncComponent(() => import("./Dashboard.vue"));
</script>

Or use await directly in <script setup> — Suspense handles it automatically:

<script setup>
// This automatically triggers Suspense's fallback while fetching
const data = await fetch("/api/dashboard").then(r => r.json());
</script>

<template>
  <div>{{ data }}</div>
</template>
Suspense is currently experimental as of Vue 3.4+. The API may change — use it for progressive enhancement, not critical paths.

Transitions & Animations

Single Element Transition

<template>
  <button @click="show = !show">Toggle</button>

  <!-- Transition wraps a single element. name="fade" maps to CSS classes.
       Vue adds/removes CSS classes at specific points during the animation:
       .fade-enter-active, .fade-leave-active, .fade-enter-from, .fade-leave-to -->
  <Transition name="fade">
    <p v-if="show">Hello Vue Transition!</p>
  </Transition>
</template>

<style>
/* .fade-enter-active and .fade-leave-active define the animation duration */
.fade-enter-active, .fade-leave-active {
  transition: opacity 0.5s ease;
}
/* .fade-enter-from and .fade-leave-to define the start/end states */
.fade-enter-from, .fade-leave-to {
  opacity: 0;
}
</style>

List Transitions with TransitionGroup

<template>
  <button @click="addItem">Add</button>
  <!-- TransitionGroup renders a real <ul> and animates item enter/leave/move -->
  <TransitionGroup name="list" tag="ul">
    <li v-for="item in items" :key="item.id">{{ item.text }}</li>
  </TransitionGroup>
</template>

<style>
.list-enter-active, .list-leave-active {
  transition: all 0.4s ease;
}
.list-enter-from, .list-leave-to {
  opacity: 0;
  transform: translateX(30px);
}
.list-move {
  transition: transform 0.4s ease;
}
</style>

Custom Directives

Directives run DOM manipulation logic on elements. Create one when you need reusable DOM behavior:

// directives/v-focus.js — auto-focus an element when mounted
export default {
  mounted(el) {
    el.focus();   // Runs when the element is inserted into the DOM
  }
};
<script setup>
import vFocus from "./directives/v-focus";
</script>

<template>
  <!-- v-focus auto-focuses this input when the component mounts -->
  <input v-focus placeholder="Auto-focused" />
</template>

Directive with Value

// v-highlight — highlights element with a custom color
export default {
  mounted(el, binding) {
    el.style.background = binding.value || "yellow";
  },
  updated(el, binding) {
    el.style.background = binding.value;  // Update if value changes
  }
};
<template>
  <p v-highlight="'lightgreen'">This is highlighted</p>
</template>

Render Functions

When templates aren’t flexible enough (dynamic heading levels, programmatic HTML generation), use render functions:

import { h } from "vue";

export default {
  props: { level: { type: Number, default: 1 } },
  render() {
    // h(tag, props/attrs, children)
    // Returns a virtual DOM node (vnode)
    return h(
      `h${this.level}`,           // Dynamic tag name (h1, h2, h3, ...)
      { class: "heading" },        // Attributes and props
      this.$slots.default?.()      // Slot content as children
    );
  }
};

Error Handling

Component-Level — errorCaptured

<script>
export default {
  errorCaptured(err, instance, info) {
    console.error("Caught error:", err, info);
    // Return false to prevent error from propagating to parent
    return false;
  }
};
</script>

Global Error Handler

const app = createApp(App);
app.config.errorHandler = (err, instance, info) => {
  console.error("Global error:", err);
  // Send to error tracking service (Sentry, LogRocket, etc.)
};

Performance Optimization

<script setup>
import { shallowRef, markRaw, nextTick } from "vue";

// shallowRef — for large data that doesn't need deep reactivity.
// Only obj.value = newValue triggers reactivity.
// Nested changes (obj.value[0] = x) are NOT tracked — faster for big arrays.
const largeArray = shallowRef([]);

// markRaw — permanently mark an object as non-reactive.
// Use for static configs, large data, or third-party instances
// that should never be wrapped in reactive proxies.
const staticConfig = markRaw({ apiKey: "abc", endpoint: "/api" });

// nextTick — wait for DOM to update after state change.
// Useful when you need to measure or interact with DOM after update.
async function updateAndScroll() {
  count.value++;
  await nextTick();      // Dom is now updated — safe to measure
  // Safe to measure or scroll based on new DOM state
}
</script>

Common Mistakes

1. Overusing reactive() instead of ref()

reactive cannot hold primitives and loses reactivity on reassignment. Start with ref for everything.

2. Not cleaning up in composables

Always use onUnmounted to remove event listeners, observers, or timers created in composables. Memory leaks are silent killers.

3. Mutating Pinia store state outside actions

Pinia allows direct mutations in templates, but use actions for complex mutations to maintain auditability.

4. Forgetting :key on TransitionGroup children

Without unique keys, enter/leave/move animations won’t work correctly.

5. Using Teleport with SSR without checking

Teleport content isn’t available on the server. Guard with if (process.client) or use client-only rendering.

6. Putting too much in the global error handler

The global handler catches everything. Use errorCaptured in specific components for granular control.

Practice Questions

1. When should you use Composition API instead of Options API? For any new code — it has better TypeScript support, cleaner code organization, and composable-based reusability without mixin drawbacks.

2. What’s the difference between ref and reactive? ref holds any value (primitives included) and requires .value in script. reactive only accepts objects with direct access.

3. How do composables differ from mixins? Composables are functions returning reactive values — no name conflicts, explicit imports, and better tree-shaking.

4. What problem does Teleport solve? It renders content to a different DOM node (like document.body) while preserving Vue’s component hierarchy — solving z-index and overflow clipping issues.

5. What is Pinia and when should you use it? Pinia is the official Vue state management library. Use it for state shared across multiple components (cart, auth, notifications).

Challenge: Build a mini e-commerce app with the Composition API and Pinia. Include a product listing page, a shopping cart (with add/remove/quantity), a discount code system, and a checkout flow. Use a composable for the data fetching logic.

FAQ

When should I use Composition API vs Options API?
Composition API is the modern standard with better code organization, TypeScript support, and composable reuse. Use Options API only for legacy code.
What is the difference between ref and reactive?
ref holds any value type (accessed via .value) and works with primitives. reactive only accepts objects and provides direct access.
How do composables differ from mixins?
Composables are functions that return reactive values — no name conflicts, explicit imports, and better tree-shaking. Mixins pollute the component namespace.
What does Teleport do?
Teleport renders its content to a different DOM node (like document.body) while preserving Vue’s component hierarchy and event handling.
How do I create global state in Vue?
Use Pinia. It provides stores with state, getters, and actions that are accessible from any component.
What is the errorCaptured lifecycle hook?
A lifecycle hook that catches errors in child components. Works like React’s Error Boundaries but at the component level.

Try It Yourself

Copy this into a new HTML file and open it in your browser:

<!DOCTYPE html>
<html>
<head>
  <title>Vue Composition API Demo</title>
  <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
  <style>
    body { font-family: sans-serif; max-width: 500px; margin: 40px auto; padding: 0 20px; }
    .card { border: 1px solid #ddd; padding: 12px; border-radius: 8px; margin: 8px 0; display: flex; justify-content: space-between; align-items: center; }
    button { background: #42b883; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; }
    input { padding: 6px 12px; border: 1px solid #ddd; border-radius: 4px; margin-right: 8px; }
  </style>
</head>
<body>
  <div id="app">
    <h1>Shopping Cart</h1>
    <div>
      <input v-model="newItem" placeholder="Add item" @keyup.enter="addItem">
      <button @click="addItem">Add</button>
    </div>
    <div v-for="(item, i) in items" :key="i" class="card">
      <span>{{ item }}</span>
      <button @click="removeItem(i)"></button>
    </div>
    <p><strong>Total items:</strong> {{ itemCount }}</p>
  </div>

  <script>
    const { createApp, ref, computed } = Vue;

    createApp({
      setup() {
        const items = ref(["Vue Mug", "JS T-Shirt"]);
        const newItem = ref("");

        const itemCount = computed(() => items.value.length);

        function addItem() {
          if (newItem.value.trim()) {
            items.value.push(newItem.value.trim());
            newItem.value = "";
          }
        }

        function removeItem(index) {
          items.value.splice(index, 1);
        }

        return { items, newItem, itemCount, addItem, removeItem };
      }
    }).mount("#app");
  </script>
</body>
</html>

What to try: Add items to the list, remove them, and watch the item count update reactively. Notice how setup() replaces data(), methods, and computed with a single unified function.

What’s Next

ResourceDescription
https://tutorials.dodatech.com/frameworks/vue/reference/Complete Vue.js API reference
Node.jsBuild backend APIs for your Vue apps
REST APIConnect Vue to RESTful backends
TypeScriptAdd full type safety to your Vue projects

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

What’s Next

Congratulations on completing this Vue Advanced tutorial! Here’s where to go from here:

  • Practice daily — Consistency is more important than long study sessions
  • Build a project — Apply what you learned by building something real
  • Explore related topics — Check out other tutorials in the same category
  • Join the community — Discuss with other learners and share your progress

Remember: every expert was once a beginner. Keep coding!

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro