Vue 3 and Nuxt · Vue 3 Fundamentals
Composables: Reusable Stateful Logic
Extract reusable stateful logic into composables that return refs, accept reactive inputs, and clean up after themselves with effect scopes.
What you'll build
Two composables run in Node: useSearch, which debounces a reactive query and tracks loading, results and errors, and usePolling, which starts a timer and cleans it up when its effect scope stops (as it would when a component unmounts).
The idea
- A composable is a function (
useX) that uses reactivity APIs and returns refs, so components share logic without mixins. - Accept refs or getters as inputs (
toValue()reads either) and react to changes withwatch. - Register cleanup with
onScopeDispose(oronUnmountedin components); every component setup runs inside an effect scope that stops on unmount, which also stops watchers. - Return refs, not reactive objects, so callers can destructure.
The code
import { ref, watch, toValue, effectScope, onScopeDispose } from "vue";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const catalogue = ["Mug", "Teapot", "Tea strainer", "Kettle"];
async function searchApi(q) {
console.log(` api called with "${q}"`);
await sleep(20);
if (q === "error") throw new Error("search service unavailable");
return catalogue.filter((name) => name.toLowerCase().includes(q.toLowerCase()));
}
function useSearch(query, { delay = 50 } = {}) {
const results = ref([]);
const loading = ref(false);
const error = ref(null);
let timer;
watch(() => toValue(query), (q) => {
clearTimeout(timer);
timer = setTimeout(async () => {
loading.value = true;
error.value = null;
try { results.value = await searchApi(q); }
catch (e) { error.value = e.message; results.value = []; }
finally { loading.value = false; }
}, delay);
});
onScopeDispose(() => clearTimeout(timer));
return { results, loading, error };
}
function usePolling(callback, ms) {
const id = setInterval(callback, ms);
onScopeDispose(() => { clearInterval(id); console.log("polling stopped"); });
}
const scope = effectScope(); // a component's setup runs in a scope like this
const query = ref("");
let polls = 0;
const { results, loading, error } = scope.run(() => {
usePolling(() => polls++, 10); // composables are called synchronously in setup
return useSearch(query);
});
query.value = "t"; query.value = "te"; query.value = "tea"; // typing quickly: one API call
await sleep(120);
console.log("results:", results.value, "loading:", loading.value);
query.value = "error";
await sleep(120);
console.log("error:", error.value);
console.log("polling ran:", polls > 10 ? "yes" : "no");
scope.stop(); // like unmounting the component
Output
api called with "tea" results: [ 'Teapot', 'Tea strainer' ] loading: false api called with "error" error: search service unavailable polling ran: yes polling stopped
Run this on your computer — see “Get Started” for setup.
Common mistakes
- Starting timers, listeners or subscriptions in a composable without cleaning them up, leaking work after unmount.
- Returning a
reactiveobject, which callers can't destructure without losing reactivity. - Calling composables conditionally, outside
setup, or after anawaitin setup: there's no active scope or component instance then, so their cleanup is never registered. - Writing composables that fetch on the server and again in the browser in SSR apps; in Nuxt use
useFetch/useAsyncData.
Key points
- Composables return refs and accept refs or getters.
- Debounce and handle loading and errors inside the composable.
- Clean up with onScopeDispose so unmounting stops all work.
Further reading: Composables · effectScope
Practice questions
Why should composables return refs instead of a reactive object?
Answer
Callers usually destructure the result; refs stay reactive when destructured, properties of a reactive object don't.
How does a composable clean up timers or listeners?
Answer
By registering cleanup with onScopeDispose (or onUnmounted); when the component unmounts its effect scope stops and the cleanup runs.
What does toValue do in a composable?
Answer
It normalizes an input that may be a plain value, a ref or a getter into its current value, so composables accept all three.
Open in the app to mark lessons complete, take notes and track your progress.