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
| Convention | Example |
|---|---|
| Function name | useCounter, useFetch, useLocalStorage |
| File name | useCounter.ts, composables/useFetch.ts |
| Return value | Refs, 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:
<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
countstays reactive in the component template
useFetch Pattern
src/composables/useFetch.ts:
Usage:
<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
| Tool | Scope | Use |
|---|---|---|
| Composable | Per component instance (unless shared refs carefully) | Reusable logic |
| Pinia | App-wide store | User session, cart |
| provide/inject | Subtree | Theme, wizard context |
Global singleton composable anti-pattern:
// 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:
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.