All posts

The Power of Composables: Patterns That Scale

Vue 3 composable patterns beyond the basics — real architecture for real apps

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: you extract logic into a function, return reactive state, and you're done.

They can do a lot more than track a mouse position, though. Used deliberately, composables end up being the architecture of your app. They can replace stores for a lot of state, and they leave your components almost embarrassingly simple.

Four patterns matter most in production.

Pattern 1: Shared state without a store

You don't always need Pinia. If your state belongs to one feature and not the whole app, a composable with module-level state is enough:

// 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,
  };
}

notifications is declared outside the function, so every component that calls useNotifications() shares the same reactive array. It's a singleton on purpose.

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 defineStore or storeToRefs.

Use this instead of Pinia when the state belongs to a feature. Notifications, toasts, a shopping cart and a sidebar toggle are all good candidates.

Pattern 2: Async lifecycle composable

You'll see this routine in almost every real app: load data when the component mounts, handle loading and error states, maybe refresh on an interval. Extract it once so you stop repeating it in every component:

// 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 call gives the component loading state, error handling and auto-refresh. It doesn't need to know how the data arrives.

Yes, Nuxt has useAsyncData built in. The pattern is still worth understanding, because you'll write variations of it in any Vue project.

Pattern 3: Composable composition

Composables get more useful when they call other composables, 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 };
}

useProductSearch uses useAsyncData for data fetching and useNotifications for user feedback. Each composable does one thing.

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, and the complexity lives in composables you can test and reuse independently 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. Signs you're over-abstracting:

  • It's used in one place. If only one component uses it, keep the logic in that component, and extract when you need the reuse.
  • It's a wrapper around one ref. const 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 a utility function and belongs 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 does none of those, 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 keeps the state, logic and lifecycle of one behavior together, and your components become thin shells that wire composables to the template.

The code is cleaner, and I think it holds up better as an app grows.

TL;DR: Use module-level state for lightweight stores, an async lifecycle composable for data fetching, composition for complex features, and wrappers to make browser APIs reactive. Skip the composable when a plain function or local state will do.