Composables

Introduction

A composable is a reusable function that uses Vue’s reactivity APIs—ref, computed, watch, lifecycle hooks—to encapsulate stateful logic. Name them useSomething and call them from <script setup> to share behavior across components without copy-paste or deep prop chains.

Prerequisites

Naming and Rules

ConventionExample
Function nameuseCounter, useFetch, useLocalStorage
File nameuseCounter.ts, composables/useFetch.ts
Return valueRefs, computeds, methods—consistent shape per composable

Composables must call lifecycle hooks synchronously during setup—not inside async callbacks or after await at top level of setup without Suspense.

Unlike React hooks, composables can be called conditionally—but keep call order predictable for readability.

useCounter Example

src/composables/useCounter.ts:

CounterPanel.vue:

vue
<script setup lang="ts">
import { useCounter } from "@/composables/useCounter";
 
const { count, doubled, increment, reset } = useCounter(10);
</script>
 
<template>
  <p>Count: {{ count }} (doubled: {{ doubled }})</p>
  <button @click="increment()">+1</button>
  <button @click="reset">Reset</button>
</template>

Code explanation:

  • Logic lives outside the component file—testable and reusable
  • Returned count stays reactive in the component template

useFetch Pattern

src/composables/useFetch.ts:

Usage:

vue
<script setup lang="ts">
import { useFetch } from "@/composables/useFetch";
 
type Post = { id: number; title: string };
 
const { data, error, loading } = useFetch<Post[]>(
  "https://jsonplaceholder.typicode.com/posts?_limit=5"
);
</script>

Expanded API patterns in Fetching API and CORS.

useLocalStorage

Warning

Do not store JWT or passwords in localStorage without understanding XSS risk—prefer httpOnly cookies for sensitive auth when backend supports it.

Lifecycle Inside Composables

Each component that calls useWindowWidth() registers its own listeners—expected behavior.

Composable vs Pinia vs Provide

ToolScopeUse
ComposablePer component instance (unless shared refs carefully)Reusable logic
PiniaApp-wide storeUser session, cart
provide/injectSubtreeTheme, wizard context

Global singleton composable anti-pattern:

typescript
// Avoid unless intentional module-level state
const globalCount = ref(0);
export function useGlobalCount() {
  return { globalCount };
}

Use Pinia for intentional shared state—Pinia.

Testing Composables

Test in Vitest by calling the function in a test (hooks run in component context with @vue/test-utils mount wrapper, or use @vue/test-utils withSetup helper pattern).

Simple pure logic (no lifecycle) tests directly:

typescript
import { describe, it, expect } from "vitest";
import { useCounter } from "./useCounter";
 
describe("useCounter", () => {
  it("increments", () => {
    const { count, increment } = useCounter(0);
    increment();
    expect(count.value).toBe(1);
  });
});

Full component testing in Testing Vue Components.

Practice Exercise

Create useToggle(initial: boolean):

  • Returns state, toggle, setTrue, setFalse
  • Use in two different components on the same page

FAQ

Composable vs utility function?

Composables use Vue reactivity or lifecycle; plain formatDate() helpers need not be composables.

Share state between components?

Use Pinia or provide/inject, not a hidden module ref unless documented.

async setup in composable?

Call onMounted for fetch; top-level await in setup needs Suspense.

Same as React hooks?

Similar reuse goal; Vue composables are not restricted by hook order rules.

File location?

src/composables/ is common; alias @/composables from Vite config.

TypeScript generics?

useFetch<T> pattern keeps API responses typed—pairs with TypeScript with Vue.