✨Vue 3 Composition API: Building Scalable, Maintainable Components

Deepa ElangoDeepa Elango
5 min read

Hook:
β€œWhy Vue's Composition API is changing how we think about component architecture in 2025.” πŸ₯‚

If you’ve worked on a growing Vue app, you know the struggle: Options API works fine for tiny components, but big components become messy spaghetti 🍝. The Composition API changes everything by letting you group logic by feature, making components cleaner, reusable, and easier to test.


1. Quick Before/After Example πŸ”„

Before (Options API – messy πŸ˜…):

<template>
  <div>
    <input v-model="name" />
    <button @click="submitForm">Submit</button>
  </div>
</template>

<script>
export default {
  data() { return { name: '', loading: false }; },
  methods: {
    submitForm() {
      this.loading = true;
      fetch('/api/submit', { method: 'POST', body: JSON.stringify({ name: this.name }) })
        .finally(() => (this.loading = false));
    }
  }
};
</script>

After (Composition API – clean & reusable ✨):

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

const name = ref('');
const loading = ref(false);

async function submitForm() {
  loading.value = true;
  try {
    await fetch('/api/submit', { method: 'POST', body: JSON.stringify({ name: name.value }) });
  } finally {
    loading.value = false;
  }
}
</script>

<template>
  <div>
    <input v-model="name" />
    <button @click="submitForm" :disabled="loading">Submit</button>
  </div>
</template>

Why this rocks: 🎸

  • Logic centralized in setup()

  • Explicit reactive state (ref())

  • Ready for reusable composables


2. Composition API Fundamentals πŸ“š

Core tools:

  • setup(): Entry point for component logic

  • ref() vs reactive(): Primitives vs objects/arrays

  • computed(): Derived reactive values

  • watch(): React to state changes

  • Lifecycle Hooks: onMounted(), onUpdated(), onUnmounted()

TypeScript Example (strong typing πŸ›‘οΈ):

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

const count = ref<number>(0);
const doubleCount = computed<number>(() => count.value * 2);

function increment(): void { count.value++; }
</script>

βœ… Benefit: Better autocompletion, fewer runtime errors, and maintainable code.


3. Real-World Composables πŸ”§

A. useFetch (with full lifecycle + error handling):

import { ref, onMounted } from 'vue';

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

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

  onMounted(fetchData);

  return { data, loading, error, fetchData };
}

B. useLocalStorage:

import { ref, watch } from 'vue';

export function useLocalStorage<T>(key: string, initialValue: T) {
  const stored = localStorage.getItem(key);
  const data = ref<T>(stored ? JSON.parse(stored) : initialValue);

  watch(data, (val) => localStorage.setItem(key, JSON.stringify(val)), { deep: true });

  return data;
}

C. useDebounce:

import { ref, watch } from 'vue';

export function useDebounce<T>(value: T, delay = 300) {
  const debounced = ref(value);
  let timer: ReturnType<typeof setTimeout>;

  watch(() => value, (val) => {
    clearTimeout(timer);
    timer = setTimeout(() => debounced.value = val, delay);
  });

  return debounced;
}

D. useEventListener:

import { onMounted, onUnmounted } from 'vue';

export function useEventListener(target: EventTarget, event: string, handler: EventListener) {
  onMounted(() => target.addEventListener(event, handler));
  onUnmounted(() => target.removeEventListener(event, handler));
}

βœ… Why Composables Are Awesome:

  • Reusable across components

  • Easier to test

  • Keeps setup() clean and readable


4. Advanced Patterns πŸš€

Pinia Store Example:

import { defineStore } from 'pinia';
import { ref } from 'vue';

export const useUserStore = defineStore('user', () => {
  const user = ref({ name: '', email: '' });
  function updateUser(newUser: { name: string; email: string }) { user.value = newUser; }
  return { user, updateUser };
});

Lifecycle Hooks in Composables:

import { ref, onMounted, onUnmounted } from 'vue';

export function useTimer() {
  const time = ref(0);
  let interval: ReturnType<typeof setInterval>;

  onMounted(() => { interval = setInterval(() => time.value++, 1000); });
  onUnmounted(() => clearInterval(interval));

  return { time };
}

Pro Tips for Juniors:

  • Keep composables focused

  • Clean up side effects

  • Don’t overuse reactive() for primitives


5. Complete Jest Test Example πŸ§ͺ

import { useLocalStorage } from './useLocalStorage';
import { nextTick } from 'vue';

test('useLocalStorage stores and updates value', async () => {
  const storageKey = 'username';
  localStorage.clear();

  const username = useLocalStorage(storageKey, 'John');
  expect(username.value).toBe('John');

  username.value = 'Alice';
  await nextTick();
  expect(JSON.parse(localStorage.getItem(storageKey)!)).toBe('Alice');
});

6. Migration Strategy 🧭

Checklist for Teams:

  1. Identify complex components

  2. Start small β†’ refactor components with reusable logic

  3. Gradually introduce TypeScript

  4. Test all composables

  5. Monitor performance & bundle size

Capability Matrix:

Component SizeOptions APIComposition API
Small/simpleβœ… OptionalOptional
Mediumβš–οΈRecommended
Large/complexβŒβœ…

7. Common Pitfalls & Quick Fixes ⚠️

PitfallFix
Overusing reactive() for primitivesUse ref() instead
Forgetting to clean up side effectsUse onUnmounted()
Creating too many tiny composablesGroup related logic together
Not typing reactive state in TSAdd proper TypeScript types
Using composables incorrectly in Options APIRefactor to setup()

8. Performance & Testing ⚑

  • Tree-shakable composables β†’ smaller bundle size

  • Modular logic β†’ fewer unnecessary re-renders

  • Testable composables β†’ easier unit tests


9. Real Project Insights πŸ’‘

  • Teams see faster onboarding for new developers

  • Reusable composables reduce duplicate logic by ~50%

  • Migration may take weeks for large apps, but results in cleaner, maintainable architecture


10. Conclusion & Next Steps 🎯

  • Composition API = scalable, maintainable, reusable, testable components

  • Feature-based logic grouping > Options API type-based grouping

  • Recommended for juniors wanting clean, modern Vue apps

  • Next Step: Refactor one small component, create a couple of composables, and see the magic happen ✨

20
Subscribe to my newsletter

Read articles from Deepa Elango directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Deepa Elango
Deepa Elango