Composables
Extracting and reusing stateful logic.
As components grow, the same logic starts appearing in several of them: fetching data with loading and error states, debouncing an input, syncing a value to localStorage, tracking the window size. In the Options API you'd reach for mixins, which caused name collisions and made it hard to tell where a property came from.
The Composition API's answer is the composable: a plain function that uses Vue's reactivity APIs and returns reactive state. Composables are the single most important pattern in modern Vue code.
What a composable is
A composable is a function, named useSomething by convention, that:
- Creates reactive state with
ref/reactive/computed. - Optionally registers watchers and lifecycle hooks.
- Returns the state (and functions) the component needs.
// composables/useCounter.js
import { ref, computed } from 'vue';
export function useCounter(initial = 0) {
const count = ref(initial);
const isPositive = computed(() => count.value > 0);
function increment() { count.value++; }
function reset() { count.value = initial; }
return { count, isPositive, increment, reset };
}
<script setup>
import { useCounter } from '@/composables/useCounter';
const { count, increment } = useCounter(10);
const { count: other } = useCounter(); // a separate, independent counter
</script>
Every call creates fresh state, so two components (or two calls in one component) never share a counter unless you deliberately design it that way.
Composables vs mixins. Mixins merge properties into the component invisibly. You can't see where this.loading came from, and two mixins can silently overwrite each other. Composables are explicit: const { loading } = useFetch(...) shows exactly where loading comes from, you can rename on destructure, and TypeScript infers everything.
Example 1: useLocalStorage
A ref that loads its initial value from localStorage and writes back whenever it changes. This combines ref with a deep watch:
// composables/useLocalStorage.js
import { ref, watch } from 'vue';
export function useLocalStorage(key, defaultValue) {
let initial = defaultValue;
try {
const stored = localStorage.getItem(key);
if (stored !== null) initial = JSON.parse(stored);
} catch { /* storage unavailable or invalid JSON: use the default */ }
const value = ref(initial);
watch(value, (v) => {
try { localStorage.setItem(key, JSON.stringify(v)); } catch { /* quota / private mode */ }
}, { deep: true });
return value;
}
<script setup>
const tasks = useLocalStorage('task-board', []);
const theme = useLocalStorage('theme', 'light');
</script>
The component treats tasks as an ordinary ref. Persistence is an implementation detail it doesn't need to know about. Add a few tasks below and reload the page. They'll still be there.
Example 2: useDebounce
Typing in a search box shouldn't fire a request on every keystroke. useDebounce returns a second ref that only updates once the source has stopped changing for delay ms:
// composables/useDebounce.js
import { ref, watch, onScopeDispose } from 'vue';
export function useDebounce(source, delay = 300) {
const debounced = ref(source.value);
let timer;
watch(source, (value) => {
clearTimeout(timer);
timer = setTimeout(() => { debounced.value = value; }, delay);
});
onScopeDispose(() => clearTimeout(timer)); // component unmounted: drop pending timer
return debounced;
}
onScopeDispose is the composable-friendly version of onUnmounted. It works inside components and inside a standalone effectScope(), so the composable can be reused outside components too.
Example 3: useFetch
The classic composable bundles data, error and loading state, and refetches whenever the URL changes. Accepting a ref or getter (via toValue, Vue 3.3+) lets callers pass reactive input:
// composables/useFetch.js
import { ref, watchEffect, toValue } from 'vue';
export function useFetch(url) {
const data = ref(null);
const error = ref(null);
const loading = ref(false);
watchEffect(async (onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
loading.value = true;
error.value = null;
try {
const res = await fetch(toValue(url), { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
data.value = await res.json();
} catch (e) {
if (e.name !== 'AbortError') error.value = e;
} finally {
loading.value = false;
}
});
return { data, error, loading };
}
Now composables can be composed. A search box becomes three lines:
<script setup>
import { ref, computed } from 'vue';
import { useDebounce } from '@/composables/useDebounce';
import { useFetch } from '@/composables/useFetch';
const query = ref('');
const debouncedQuery = useDebounce(query, 400);
const { data: tasks, loading, error } = useFetch(
() => `/api/tasks?search=${encodeURIComponent(debouncedQuery.value)}`
);
</script>
<template>
<input v-model="query" placeholder="Search tasks">
<p v-if="loading">Loading…</p>
<p v-else-if="error">Something went wrong: {{ error.message }}</p>
<ul v-else>
<li v-for="task in tasks" :key="task.id">{{ task.title }}</li>
</ul>
</template>
The demo runs this exact combination against a simulated API with a 600 ms delay and a 15% failure rate. The counters show how debouncing cuts the number of requests.
Example 4: wrapping browser APIs
Anything with subscribe/unsubscribe semantics is a great composable candidate, because the composable owns the cleanup:
// composables/useEventListener.js
import { onMounted, onUnmounted } from 'vue';
export function useEventListener(target, event, handler) {
onMounted(() => target.addEventListener(event, handler));
onUnmounted(() => target.removeEventListener(event, handler));
}
// composables/useWindowSize.js
import { ref } from 'vue';
import { useEventListener } from './useEventListener';
export function useWindowSize() {
const width = ref(window.innerWidth);
const height = ref(window.innerHeight);
useEventListener(window, 'resize', () => {
width.value = window.innerWidth;
height.value = window.innerHeight;
});
return { width, height };
}
Resize your browser window, or rotate your phone, while watching this demo:
Don't reinvent the wheel: VueUse ships 200+ well-tested composables (useLocalStorage, useDebounceFn, useFetch, useIntersectionObserver…). Writing your own is still the best way to understand the pattern.
Rules and conventions
- Name it
useXand put it insrc/composables/. - Call composables synchronously at the top of
setup(). Lifecycle hooks and watchers inside the composable register against the current component instance, which only exists synchronously during setup. Calling one after anawaitor inside a click handler breaks cleanup. - Return an object of refs, not a
reactive()object. Destructuring keeps reactivity with refs. If callers want a single object they can wrap it:const mouse = reactive(useMouse()). - Accept refs, getters or plain values for input and normalise with
toValue(). That makes the composable flexible:useFetch('/api/x'),useFetch(urlRef)anduseFetch(() => ...)all work. - Clean up everything you start (
onScopeDispose/onUnmounted).
Shared state on purpose
If state is created outside the function, at module level, every caller shares it. That's a lightweight global store:
// composables/useCurrentUser.js
import { ref, readonly } from 'vue';
const user = ref(null); // module scope: one instance for the whole app
export function useCurrentUser() {
async function load() { user.value = await (await fetch('/api/user')).json(); }
return { user: readonly(user), load };
}
This works well for small apps. Larger apps should use Pinia (advanced lessons), which adds devtools, SSR safety and a standard structure.
Testing a composable
Composables that don't use lifecycle hooks are just functions, so you can test them directly with Vitest:
import { describe, it, expect, vi } from 'vitest';
import { ref, nextTick } from 'vue';
import { useDebounce } from '@/composables/useDebounce';
describe('useDebounce', () => {
it('only updates after the delay', async () => {
vi.useFakeTimers();
const source = ref('a');
const debounced = useDebounce(source, 300);
source.value = 'ab';
await nextTick();
expect(debounced.value).toBe('a');
vi.advanceTimersByTime(300);
expect(debounced.value).toBe('ab');
});
});
Key takeaways
- A composable is a
useX()function that creates reactive state, optionally registers watchers or hooks, and returns refs. - Each call creates independent state. Module-level state is shared deliberately.
- Composables replace mixins: their sources are explicit, they can be renamed on destructure, and they type well.
- Call them synchronously in
setup(), accept flexible input viatoValue(), and always clean up withonScopeDispose. - Composables compose:
useFetch(useDebounce(query))builds complex behaviour from small, testable pieces.