The Power of Composables: Patterns That Scale

If you've used Vue 3 for more than a week, you've written a composable. Maybe useMouse, maybe useFetch, maybe something from VueUse. They feel natural — extract logic into a function, return reactive state, done.

But composables can do a lot more than track a mouse position. When used intentionally, they become the architecture of your app — replacing stores, abstracting complexity, and making your components almost embarrassingly simple.

Let's look at the patterns that actually matter in production.


Pattern 1: Shared state without a store

You don't always need Pinia. If your state is scoped to a feature (not the entire app), a composable with module-level state works perfectly:

// composables/useNotifications.ts
import { ref, readonly } from 'vue';

const notifications = ref<Notification[]>([]);
let nextId = 0;

interface Notification {
  id: number;
  message: string;
  type: 'success' | 'error' | 'info';
}

export function useNotifications() {
  function notify(message: string, type: Notification['type'] = 'info') {
    const id = nextId++;
    notifications.value.push({ id, message, type });

    setTimeout(() => {
      notifications.value = notifications.value.filter((n) => n.id !== id);
    }, 5000);
  }

  function dismiss(id: number) {
    notifications.value = notifications.value.filter((n) => n.id !== id);
  }

  return {
    notifications: readonly(notifications),
    notify,
    dismiss,
  };
}

The key: notifications is declared outside the function. Every component that calls useNotifications() shares the same reactive array. It's a singleton by design.

This works because ES modules are evaluated once. The ref is created when the module loads, and every import gets the same instance. You get Pinia-like behavior without Pinia — no defineStore, no storeToRefs, no boilerplate.

When to use this instead of Pinia: when the state belongs to a feature, not the app. Notifications, toasts, a shopping cart, a sidebar toggle — these are all great candidates.


Pattern 2: Async lifecycle composable

One of the most common patterns in real apps: load data when the component mounts, handle loading and error states, maybe refresh on an interval. Instead of repeating this in every component, extract it:

// composables/useAsyncData.ts
import { ref, onMounted, onUnmounted } from 'vue';

interface UseAsyncDataOptions<T> {
  immediate?: boolean;
  refreshInterval?: number;
  initialValue?: T;
}

export function useAsyncData<T>(
  fetcher: () => Promise<T>,
  options: UseAsyncDataOptions<T> = {}
) {
  const { immediate = true, refreshInterval, initialValue } = options;

  const data = ref<T | undefined>(initialValue) as Ref<T | undefined>;
  const error = ref<string | null>(null);
  const isLoading = ref(false);

  let intervalId: ReturnType<typeof setInterval> | null = null;

  async function execute() {
    isLoading.value = true;
    error.value = null;

    try {
      data.value = await fetcher();
    } catch (err: any) {
      error.value = err.message ?? 'Failed to fetch data';
    } finally {
      isLoading.value = false;
    }
  }

  if (immediate) {
    onMounted(() => {
      execute();

      if (refreshInterval) {
        intervalId = setInterval(execute, refreshInterval);
      }
    });
  }

  onUnmounted(() => {
    if (intervalId) clearInterval(intervalId);
  });

  return { data, error, isLoading, refresh: execute };
}

Now any component can do this:

<script setup lang="ts">
const { data: products, isLoading, error, refresh } = useAsyncData(
  () => $fetch('/api/products'),
  { refreshInterval: 30000 }
);
</script>

One line. Loading state, error handling, auto-refresh — all handled. The component doesn't care about how the data arrives, just that it does.

Yes, Nuxt has useAsyncData built-in. But the pattern itself is valuable to understand — you'll write variations of this in any Vue project.


Pattern 3: Composable composition

The real power shows up when composables use other composables. This is composition in the truest sense — small pieces building on each other:

// composables/useProductSearch.ts
export function useProductSearch() {
  const query = ref('');
  const filters = ref({ category: '', maxPrice: Infinity });

  const { data: allProducts, isLoading } = useAsyncData<Product[]>(
    () => $fetch('/api/products')
  );

  const { notify } = useNotifications();

  const results = computed(() => {
    if (!allProducts.value) return [];

    return allProducts.value.filter((product) => {
      const matchesQuery =
        !query.value ||
        product.name.toLowerCase().includes(query.value.toLowerCase());

      const matchesCategory =
        !filters.value.category ||
        product.category === filters.value.category;

      const matchesPrice = product.price <= filters.value.maxPrice;

      return matchesQuery && matchesCategory && matchesPrice;
    });
  });

  function search(text: string) {
    query.value = text;
    if (results.value.length === 0) {
      notify(`No results for "${text}"`, 'info');
    }
  }

  return { query, filters, results, isLoading, search };
}

Look at what happened: useProductSearch uses useAsyncData for data fetching and useNotifications for user feedback. Each composable does one thing, and they compose naturally.

The component that uses this is almost trivial:

<template>
  <input v-model="query" placeholder="Search products..." />
  <div v-if="isLoading">Loading...</div>
  <ProductGrid v-else :products="results" />
</template>

<script setup lang="ts">
const { query, results, isLoading } = useProductSearch();
</script>

Five lines of logic. The complexity lives in composables that are testable, reusable, and independent of any UI.


Pattern 4: Stateful wrappers around browser APIs

Browser APIs are imperative and callback-heavy. Composables turn them into reactive, declarative state:

// composables/useOnlineStatus.ts
import { ref, onMounted, onUnmounted } from 'vue';

export function useOnlineStatus() {
  const isOnline = ref(navigator.onLine);

  function update() {
    isOnline.value = navigator.onLine;
  }

  onMounted(() => {
    window.addEventListener('online', update);
    window.addEventListener('offline', update);
  });

  onUnmounted(() => {
    window.removeEventListener('online', update);
    window.removeEventListener('offline', update);
  });

  return { isOnline: readonly(isOnline) };
}
// composables/useMediaQuery.ts
import { ref, onMounted, onUnmounted } from 'vue';

export function useMediaQuery(query: string) {
  const matches = ref(false);

  let mediaQuery: MediaQueryList;

  function update(e: MediaQueryListEvent) {
    matches.value = e.matches;
  }

  onMounted(() => {
    mediaQuery = window.matchMedia(query);
    matches.value = mediaQuery.matches;
    mediaQuery.addEventListener('change', update);
  });

  onUnmounted(() => {
    mediaQuery?.removeEventListener('change', update);
  });

  return { matches: readonly(matches) };
}

Now your components can react to network changes or screen size without touching a single event listener:

<script setup lang="ts">
const { isOnline } = useOnlineStatus();
const { matches: isMobile } = useMediaQuery('(max-width: 768px)');
</script>

<template>
  <OfflineBanner v-if="!isOnline" />
  <MobileNav v-if="isMobile" />
  <DesktopNav v-else />
</template>

When NOT to use a composable

Not everything deserves to be a composable. Here are signs you're over-abstracting:

  • It's used in one place — if only one component uses it, keep the logic in that component. Extract when you need reuse, not before.
  • It's just a wrapper around one refconst count = ref(0) doesn't need a useCounter composable.
  • It has no reactive state — if your function doesn't use ref, computed, or lifecycle hooks, it's just a utility function. Put it in a utils/ folder.
  • The abstraction is forced — if naming the composable feels awkward, the abstraction probably isn't right.

A good composable has a clear reason to exist: it manages reactive state, it handles lifecycle, or it composes other composables. If it doesn't do any of those things, it's probably just a function — and that's fine.


The big picture

Composables are Vue's answer to the question: "Where does the logic go?"

In Options API, logic was scattered across data, computed, methods, mounted, watch... all organized by type, not by feature. You had to jump between sections to understand a single piece of functionality.

Composables flip that. Each one is a self-contained unit of behavior — state, logic, and lifecycle together. Your components become thin shells that just wire composables to the template.

That's not just cleaner code. It's a fundamentally better architecture for apps that grow.


TL;DR: Composables aren't just for useMouse. Use module-level state for lightweight stores, async lifecycle patterns for data fetching, composition for complex features, and browser API wrappers for reactive integration. Know when to use them — and when not to.