A composable is a function that uses Vue's reactivity and lifecycle APIs, named
useSomething. It is how you share logic between components, and it is the thing
the Composition API was actually created for.
Vue 2 shared logic with mixins, which merged properties into a component from a distance. Two mixins could silently overwrite each other, nothing told you where a property came from, and the component's real surface was unknowable. Composables have none of those problems, because they are just function calls with return values.
A real one
The demo application's feed needs an IntersectionObserver. That means creating it once
the DOM exists, registering elements that may not exist yet, and — the part that is easy to forget —
disconnecting it on unmount. None of that is about reels:
export function useIntersectionObserver(onIntersect, { root = null, threshold = 0 } = {}) {
let observer = null;
// Elements registered before mount. A template ref inside a v-for is not
// populated until the DOM exists, but the caller should not have to care about
// that ordering, so they are buffered and observed once the observer is built.
const pending = new Set();
const isActive = ref(false);
onMounted(() => {
observer = new IntersectionObserver(onIntersect, {
// unref() so the caller can pass either `scroller` (a ref) or an element.
root: unref(root) ?? null,
threshold,
});
isActive.value = true;
pending.forEach((el) => observer.observe(el));
pending.clear();
});
/** Watch an element. Safe to call with null, and safe to call twice —
* re-observing an element already under observation is a no-op. */
function observe(el) {
if (!el) return;
if (observer) observer.observe(el);
else pending.add(el);
}
function unobserve(el) {
if (!el) return;
pending.delete(el);
observer?.unobserve(el);
}
function stop() {
observer?.disconnect();
observer = null;
pending.clear();
isActive.value = false;
}
// The whole point. A component using this cannot leak an observer, because
// the composable that created it is the thing that tears it down.
onBeforeUnmount(stop);
return { observe, unobserve, stop, isActive };
}And what is left in the component is only the part that is about the feed:
const { observe } = useIntersectionObserver(
(entries) => {
entries.forEach((entry) => {
// 0.6 rather than 0.5: at exactly half, two slides can both qualify
// mid-scroll and the active index flickers between them.
if (entry.isIntersecting && entry.intersectionRatio >= 0.6) {
const idx = Number(entry.target.dataset.index);
activeIndex.value = idx;
// Prefetch a page before hitting the end, so the scroll never stalls.
if (idx >= reels.value.length - 2) loadPage();
}
});
},
{ root: scroller, threshold: [0.6] }
);The component no longer mentions IntersectionObserver, onMounted or
onBeforeUnmount. It cannot leak the observer, because the thing that creates it is the
thing that tears it down.
What makes it a composable
1. It calls Vue APIs, so it must be called from setup. onMounted and
friends attach to whichever component is currently being set up. Call a composable inside a callback,
a promise, or after an await, and there is no current component — the hooks silently
attach to nothing, or to the wrong component.
// Correct -- top level of <script setup>
const { observe } = useIntersectionObserver(onEntry, { root: scroller });
// WRONG -- no current component instance by the time this runs
onMounted(async () => {
await load();
const { observe } = useIntersectionObserver(onEntry);
});2. It owns its cleanup. This is the real payoff. Anything a composable starts, it must stop, so the caller cannot get it half right.
3. It returns refs, not values. Returning count.value hands over a
number and the caller loses reactivity forever. Return the ref.
Accepting reactive arguments
The second composable in the application debounces a value, which is what lets Explore search as you type without firing a request per keystroke:
export function useDebounced(source, delay = 300) {
const debounced = ref(typeof source === "function" ? source() : unref(source));
let timer = null;
watch(source, (value) => {
clearTimeout(timer);
timer = setTimeout(() => {
debounced.value = value;
}, delay);
});
// Without this, a timer that fires after the component is gone writes to a ref
// nothing is rendering any more. Harmless here; not harmless when the callback
// starts a request.
onBeforeUnmount(() => clearTimeout(timer));
// Returned READONLY by convention: the caller reads it and writes to `source`.
// Returning a writable ref invites two sources of truth for the same value.
return debounced;
}Note what the argument is. Not a string — a ref or a getter:
const debouncedTerm = useDebounced(term, 350); // a ref
const debouncedQuery = useDebounced(() => route.query.q, 350); // a getterPassing term.value would hand over a plain string. The composable would debounce one
value, once, and never update again — and nothing would warn you. This is the most common composable
bug, and it always looks like "the debounce is broken" rather than "the argument was wrong".
watch accepts either form directly, which is why the composable needs no branching to
support both. Design yours the same way.
The caller then reacts to the debounced value:
watch(debouncedTerm, (q) => {
if (q !== (route.query.q ?? "")) setFilter({ q });Conventions worth following
Name it useX. Not decoration — it is the signal that this function has
lifecycle side effects and must be called from setup.
One file per composable, in src/composables/.
Return an object, not an array. An object lets the caller take only what it needs
and rename on destructure. const { observe } = useIntersectionObserver(...) ignores the
other three returns without ceremony.
Return a stop when you start something long-lived, even though unmount
handles the usual case. It costs one line and makes the composable usable outside a component.
Composables are not stores
An important distinction, and the source of a lot of confused architecture.
Calling a composable twice gives you two independent instances. Two components each
calling useDebounced(term) get their own timer and their own ref. That is usually what you
want.
State declared at module scope in a composable's file is shared by every caller:
// Module scope -- ONE ref, shared by every component that calls this.
const user = ref(null);
export function useUser() {
return { user }; // a singleton pretending to be a composable
}That works, and people do it. But it is a global store with none of a store's tooling — no devtools timeline, no obvious home for actions, and no signal in the name that it is shared. If state should be shared, use Pinia and say so. That is lesson 17.
Custom directives, the other half of reusability
Vue groups composables, custom directives and plugins under one heading, because they answer the same question in different places. A composable reuses logic; a directive reuses low-level DOM behaviour.
// main.js
app.directive("autofocus", {
// Called once the element is in the DOM.
mounted(el) {
el.focus();
},
});<input v-autofocus class="form-control" type="email" />The hooks are created, beforeMount, mounted,
beforeUpdate, updated, beforeUnmount and
unmounted, and each receives the element plus a binding object holding the value, argument
and modifiers — so v-tooltip:top.fast="text" is all available.
Reach for a directive rarely. Vue's own guidance is that a component is almost always the better answer, and the demo application does not define a single custom directive. They earn their place for behaviour that must attach to any element without wrapping it — focus, click-outside, intersection, tooltips.
Next: provide and inject.