Vue composables are plain functions that use the Composition API to hold stateful logic you want to reuse. The idea fits in one sentence; writing a good one takes a few rules. This guide builds three composables from scratch, each a step harder than the last: useToggle, usePagination and useDebouncedSearch. Along the way it covers what to accept, what to return, how to clean up, and how to test each one without mounting a component. The code is TypeScript and works in any Vue 3.3+ app, with or without Quasar.
The Vue docs define a composable as a function that uses the Composition API to encapsulate and reuse stateful logic. That word matters. A function that formats a date takes input and returns output right away. It is a utility, not a composable. A composable owns state that changes over time, like whether a dialog is open or which page of a list you are on.
Three conventions from the Vue docs apply to every example below:
useSomething, in camelCase, starting with use.reactive() object. Refs survive destructuring; properties of a reactive object lose their reactivity when you destructure them.<script setup> or setup(). That is how Vue knows which component the composable's watchers and lifecycle hooks belong to, so it can stop them on unmount. (<script setup> is the one place where calling a composable after an await also works, because the compiler restores the component context.)Start with the smallest useful one: a boolean with named actions.
// composables/useToggle.ts
import { ref } from "vue";
export function useToggle(initial = false) {
const state = ref(initial);
const toggle = () => {
state.value = !state.value;
};
const on = () => {
state.value = true;
};
const off = () => {
state.value = false;
};
return { state, toggle, on, off };
}Use it for anything that opens and closes. Here it drives a Quasar dialog:
<template>
<q-btn label="Delete project" color="negative" @click="on" />
<q-dialog v-model="state">
<q-card>
<q-card-section>Delete this project? This can't be undone.</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" @click="off" />
<q-btn flat color="negative" label="Delete" @click="remove" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { useToggle } from "@/composables/useToggle";
const { state, on, off } = useToggle();
async function remove() {
// call your API here
off();
}
</script>Is this worth a composable for one ref? On its own, barely. The value is that on, off and toggle read better in templates than open = !open, and every dialog, drawer and dropdown in the app now works the same way. Destructuring state keeps it reactive because it is a ref, and v-model="state" works because the template unwraps it.
The second composable takes input. The question is what type: a plain array, a ref, or a computed? Accept all three. Vue 3.3 added the MaybeRefOrGetter<T> type (a value, a ref or a getter function) and the toValue() helper that reads any of them.
// composables/usePagination.ts
import { computed, ref, toValue, watch, type MaybeRefOrGetter } from "vue";
export function usePagination<T>(
items: MaybeRefOrGetter<readonly T[]>,
pageSize: MaybeRefOrGetter<number> = 10,
) {
const page = ref(1);
const pageCount = computed(() =>
Math.max(1, Math.ceil(toValue(items).length / toValue(pageSize))),
);
const pageItems = computed(() => {
const start = (page.value - 1) * toValue(pageSize);
return toValue(items).slice(start, start + toValue(pageSize));
});
watch(pageCount, (count) => {
if (page.value > count) page.value = count;
});
const next = () => {
page.value = Math.min(page.value + 1, pageCount.value);
};
const prev = () => {
page.value = Math.max(page.value - 1, 1);
};
return { page, pageCount, pageItems, next, prev };
}Two details make this one reliable:
toValue() is called inside computed. That is what tracks the dependency. If you called toValue(items) once at the top of the function, you would read the array a single time and never see it change.page in range. Filter a list from 5 pages down to 2 while the user is on page 4, and page drops to 2 instead of showing an empty page.Because the input can be a getter, the caller can pass a derived list without creating a computed first. Paired with q-pagination, page binds directly:
<template>
<q-list bordered separator>
<q-item v-for="user in pageItems" :key="user.id">
<q-item-section>{{ user.name }}</q-item-section>
</q-item>
</q-list>
<q-pagination v-model="page" :max="pageCount" direction-links class="q-mt-md" />
</template>
<script setup lang="ts">
import { ref } from "vue";
import { usePagination } from "@/composables/usePagination";
const users = ref([
{ id: 1, name: "Ana Lima", active: true },
{ id: 2, name: "Tom Reyes", active: false },
// ...
]);
const { page, pageCount, pageItems } = usePagination(
() => users.value.filter((user) => user.active),
5,
);
</script>The third composable has side effects: a timer and a network request. Both must be cancelled when the query changes or the component goes away, or you get results for an old query arriving after the new one.
// composables/useDebouncedSearch.ts
import { ref, shallowRef, toValue, watch, type MaybeRefOrGetter } from "vue";
export function useDebouncedSearch<T>(
query: MaybeRefOrGetter<string>,
search: (term: string, signal: AbortSignal) => Promise<T[]>,
delay = 300,
) {
const results = shallowRef<T[]>([]);
const loading = ref(false);
const error = ref<unknown>(null);
watch(
() => toValue(query).trim(),
(term, _previous, onCleanup) => {
const controller = new AbortController();
const timer = setTimeout(async () => {
if (!term) {
results.value = [];
loading.value = false;
return;
}
loading.value = true;
error.value = null;
try {
const found = await search(term, controller.signal);
if (!controller.signal.aborted) results.value = found;
} catch (caught) {
if (!controller.signal.aborted) error.value = caught;
} finally {
if (!controller.signal.aborted) loading.value = false;
}
}, delay);
onCleanup(() => {
clearTimeout(timer);
controller.abort();
});
},
{ immediate: true },
);
return { results, loading, error };
}How the cleanup works: watch calls the onCleanup callback before it runs again and when the watcher stops. A new keystroke clears the pending timer and aborts the request in flight. When the component unmounts, Vue stops the watcher, which runs the same cleanup. There is no onUnmounted in the composable, so it also works outside a component, as long as something stops the watcher (more on that in the testing section).
results is a shallowRef because the whole array is replaced on each search. Vue doesn't need to make every result object deeply reactive.
In a component:
<template>
<q-input v-model="query" label="Search customers" outlined clearable :loading="loading" />
<q-list>
<q-item v-for="customer in results" :key="customer.id">
<q-item-section>{{ customer.name }}</q-item-section>
</q-item>
</q-list>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useDebouncedSearch } from "@/composables/useDebouncedSearch";
interface Customer {
id: number;
name: string;
}
const query = ref<string | null>("");
const { results, loading } = useDebouncedSearch<Customer>(
() => query.value ?? "",
(term, signal) =>
fetch(`/api/customers?q=${encodeURIComponent(term)}`, { signal }).then((r) => r.json()),
);
</script>clearable sets the model to null, which is why the getter falls back to an empty string. QInput also has a debounce prop. It is enough when you only want fewer updates. The composable earns its place by also aborting the stale request and tracking loading and error for you.
The three examples follow the same rules. Written out:
MaybeRefOrGetter and read it with toValue() inside a computed or a watcher. Never unwrap inputs once at the top.usePagination returns the page, the count, the current slice and two actions. Its watcher stays inside.onCleanup in watchers, onScopeDispose() for listeners and intervals you start yourself, and onMounted() for anything that touches the DOM, so it never runs during server rendering.Where these files live as an app grows (by feature, with composables next to the components that use them) is covered in the post on structuring Quasar projects.
A composable that only uses reactivity APIs can be tested by calling it, no component needed. That covers useToggle and usePagination:
import { ref, nextTick } from "vue";
import { expect, test } from "vitest";
import { usePagination } from "./usePagination";
test("pulls the page back in range when the list shrinks", async () => {
const items = ref(Array.from({ length: 30 }, (_, i) => i));
const { page, pageCount, next } = usePagination(items, 10);
next();
next();
expect(page.value).toBe(3);
items.value = items.value.slice(0, 15);
await nextTick();
expect(pageCount.value).toBe(2);
expect(page.value).toBe(2);
});useDebouncedSearch starts a watcher, so run it inside an effectScope and stop the scope at the end. Stopping it runs the cleanup, the same as an unmount:
import { effectScope, ref } from "vue";
import { expect, test, vi } from "vitest";
import { useDebouncedSearch } from "./useDebouncedSearch";
test("searches once, after the user stops typing", async () => {
vi.useFakeTimers();
const search = vi.fn(async (term: string) => [term]);
const query = ref("");
const scope = effectScope();
const { results } = scope.run(() => useDebouncedSearch(query, search))!;
query.value = "an";
await vi.advanceTimersByTimeAsync(100);
query.value = "ana";
await vi.advanceTimersByTimeAsync(300);
expect(search).toHaveBeenCalledTimes(1);
expect(results.value).toEqual(["ana"]);
scope.stop();
vi.useRealTimers();
});Composables that use lifecycle hooks or inject need a host component. The Vue testing guide shows a small withSetup helper that mounts a throwaway app for that.
Composable or utility function? If it holds state with ref, computed or a watcher, it is a composable. If it only maps input to output, keep it a plain function.
Composable or component? Reusing logic only: composable. Reusing logic and markup: component. A component that wraps a form field with defineModel is the second kind, covered in Vue defineModel.
Can two components share a composable's state? Not by default: every call creates new refs. Move the ref outside the function to share it, and be careful with that in SSR, where module state is shared between requests.
Composables keep the logic of a page small and testable; the markup still has to be built. The QuasarUI Components are ready-made Quasar sections (data tables, form layouts, dialogs, stat cards) that you paste into a page and wire to composables like these. 100 of them are Free to copy without signing in.