Vue Tutorial · Lessons

Foundation

Basic

Medium

Advanced

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:

  1. Creates reactive state with ref / reactive / computed.
  2. Optionally registers watchers and lifecycle hooks.
  3. 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 (plain functions) COMPONENTS (each call = own state) useFetch(url) useDebounce(ref) useLocalStorage(key) TaskBoard.vue TaskSearch.vue Settings.vue Logic is shared; state is not, unless the composable deliberately keeps it at module level.

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 useX and put it in src/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 an await or 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) and useFetch(() => ...) 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 via toValue(), and always clean up with onScopeDispose.
  • Composables compose: useFetch(useDebounce(query)) builds complex behaviour from small, testable pieces.