
A catalog of practical patterns for writing composables that are flexible, predictable, and easy to reuse.
Reza Baar
September 23, 2026
Composables are how you share logic in Vue, but "extract logic into a function" leaves a lot of decisions open. How should the composable return its values? How should it accept arguments? Should it manage its own lifecycle? In this post, we'll go through the patterns that come up again and again, so you have a vocabulary for writing composables that are flexible and predictable rather than one-off.
The first decision is what to return. Two common choices are an object of refs or a single reactive object. The object-of-refs approach is the more flexible default because it lets consumers destructure without losing reactivity:
// composables/useCounter.ts
export function useCounter(initial = 0) {
const count = ref(initial);
const double = computed(() => count.value * 2);
function increment() {
count.value++;
}
return { count, double, increment };
}
Consumers destructure freely, and each piece stays reactive:
const { count, double, increment } = useCounter(10);
Compare this to returning a reactive object. If you return reactive({ count, double }), destructuring it breaks reactivity, and consumers have to remember to use toRefs or access everything through the object. The object-of-refs pattern avoids that footgun. Reach for a single reactive object only when the values are truly one cohesive unit that's always used together.
A composable is more reusable when it accepts plain values, refs, and getters as arguments. If it only accepts a plain value, it can't react to a changing input. If it only accepts a ref, callers with a static value have to wrap it, and callers with a derived expression can't pass it directly. Accepting MaybeRefOrGetter handles all three.
Use toValue to normalize the argument, and read it inside reactive contexts so it tracks changes:
// composables/useDoubled.ts
import type { MaybeRefOrGetter } from 'vue';
export function useDoubled(value: MaybeRefOrGetter<number>) {
return computed(() => toValue(value) * 2);
}
toValue unwraps refs, calls getters, and passes plain values through. This means all three of these work:
// Plain value
const a = useDoubled(5);
// Ref
const count = ref(5);
const b = useDoubled(count);
// Getter
const c = useDoubled(() => count.value + 1);
MaybeRefOrGetter combined with toValue is the standard way to write composable inputs. It's what Vue's own official composables use, and it makes your composable work no matter how the caller holds its data.
For composables that fetch data, a consistent shape is: return the data, a loading indicator, an error, and a way to re-run. This gives consumers everything they need to render every state.
// composables/useFetch.ts
export function useAsyncState<T>(fetcher: () => Promise<T>) {
const data = ref<T | null>(null);
const isLoading = ref(false);
const error = ref<Error | null>(null);
async function execute() {
isLoading.value = true;
error.value = null;
try {
data.value = await fetcher();
} catch (e) {
error.value = e as Error;
} finally {
isLoading.value = false;
}
}
return { data, isLoading, error, execute };
}
The important choice here is that the composable doesn't fetch automatically on creation. It returns execute and lets the consumer decide when to run it. This makes the composable predictable: creating it has no side effects. If you want it to run immediately, the consumer opts in:
const { data, isLoading, error } = useAsyncState(() =>
fetch('/api/user').then((r) => r.json())
);
// Consumer decides when
onMounted(execute);
Composables can register their own lifecycle hooks and clean up after themselves. When a composable sets up something that needs teardown (an event listener, an interval, a subscription), it should handle both ends inside the composable. The consumer shouldn't have to remember to clean up.
// composables/useEventListener.ts
export function useEventListener(
target: EventTarget,
event: string,
handler: (e: Event) => void
) {
onMounted(() => {
target.addEventListener(event, handler);
});
onUnmounted(() => {
target.removeEventListener(event, handler);
});
}
The consumer just calls it, and cleanup is automatic:
useEventListener(window, 'resize', () => {
console.log('resized');
});
This is the pattern that makes composables feel like magic. All the setup-and-teardown bookkeeping lives in one place, and using it is a single line. The key is that a composable calling lifecycle hooks must be called synchronously in setup (or another composable), so the hooks register against the right component instance.
There's an important distinction in where a composable's state lives. If you create the state inside the composable function, every caller gets its own copy. If you create it in module scope (outside the function), all callers share one instance.
Per-instance is the default and usually what you want:
// Per-instance: each caller gets its own count
export function useCounter() {
const count = ref(0); // created per call
return { count };
}
Shared state is a deliberate choice for things that should be global, like the current user or a toggle that affects the whole app:
// composables/useDarkMode.ts
// Created once, in module scope, shared by all callers
const isDark = ref(false);
export function useDarkMode() {
function toggle() {
isDark.value = !isDark.value;
}
return { isDark, toggle };
}
Every component that calls useDarkMode() reads and writes the same isDark. This is a lightweight way to share global state without a store, for cases too small to justify Pinia. Be intentional about which one you're writing, because accidentally sharing state (or accidentally not sharing it) is a common source of bugs.
A note on SSR: module-scoped shared state is not SSR-safe, because the module is shared across all requests on the server, so one user's state can leak into another's. In a Nuxt or SSR app, use
useStatefor shared state instead. In a client-only app, module scope is fine.
It helps to separate composables that talk to the outside world from composables that are pure logic. A "connected" composable fetches data, hits an API, or reads global state. A "presentational" composable takes inputs and returns derived values with no side effects.
The presentational kind is trivially testable because it's just input-to-output:
// Presentational: pure logic, no side effects
export function usePagination(total: MaybeRefOrGetter<number>, perPage = 10) {
const page = ref(1);
const pageCount = computed(() => Math.ceil(toValue(total) / perPage));
const canNext = computed(() => page.value < pageCount.value);
const canPrev = computed(() => page.value > 1);
function next() {
if (canNext.value) page.value++;
}
function prev() {
if (canPrev.value) page.value--;
}
return { page, pageCount, canNext, canPrev, next, prev };
}
The connected kind wires the pure logic to real data:
// Connected: talks to an API, composes the presentational one
export function usePaginatedPosts() {
const { data: total } = useAsyncState(() => fetchPostCount());
const pagination = usePagination(() => total.value ?? 0);
const { data: posts, execute } = useAsyncState(() =>
fetchPosts(pagination.page.value)
);
watch(pagination.page, execute);
return { posts, ...pagination };
}
Keeping the pure logic separate means you can unit-test usePagination with plain numbers, no mocking required, and reuse it anywhere.
When a composable wraps another composable or an internal ref, rename the returned values to something meaningful at the boundary. This gives consumers a clear API without leaking internal names:
// composables/useProducts.ts
export function useProducts() {
const { data, isLoading, error, execute } = useAsyncState(() =>
fetch('/api/products').then((r) => r.json())
);
const isEmpty = computed(() => !data.value?.length);
// Rename `data` to `products` at the boundary
return {
products: data,
isLoading,
error,
isEmpty,
refresh: execute,
};
}
The internal data and execute become the clearer products and refresh. Consumers get a self-documenting API, and you're free to change the internals without affecting them.
When a composable has more than one or two optional settings, an options object (or a bag) beats a long list of positional arguments. It's clearer at the call site and easy to extend without breaking existing calls:
// composables/useIntervalFn.ts
interface UseIntervalOptions {
immediate?: boolean;
interval?: number;
}
export function useIntervalFn(
callback: () => void,
options: UseIntervalOptions = {}
) {
const { immediate = true, interval = 1000 } = options;
let id: ReturnType<typeof setInterval> | null = null;
function start() {
stop();
id = setInterval(callback, interval);
}
function stop() {
if (id !== null) {
clearInterval(id);
id = null;
}
}
if (immediate) start();
onUnmounted(stop);
return { start, stop };
}
At the call site, named options read better than useIntervalFn(cb, true, 2000):
const { start, stop } = useIntervalFn(tick, { interval: 2000, immediate: false });
MaybeRefOrGetter arguments and normalize with toValue so composables work with plain values, refs, and gettersuseState for shared state in SSRThese patterns aren't rules so much as a shared vocabulary. Once you recognize "this is a self-managing lifecycle composable" or "this should be presentational so I can test it," the design decisions get easier. The through-line is predictability: a good composable is flexible in what it accepts, clear in what it returns, and honest about its side effects.
Get the latest news and updates on developer certifications. Content is updated regularly, so please make sure to bookmark this page or sign up to get the latest content directly in your inbox.

A Reference to Composable Patterns in Vue
A catalog of practical patterns for writing composables that are flexible, predictable, and easy to reuse.
Reza Baar
Sep 23, 2026

AbortController: Cancellation and Cleanup in JavaScript
AbortController does more than cancel a fetch. Learn how one signal cleans up event listeners, times out requests, and combines with AbortSignal.any().
Martin Ferret
Sep 22, 2026

What's New in React 19.3
Explore what's new in React 19.3, including stable View Transitions, Fragment Refs, browser-only rendering, Trusted Types, and RSC updates.
Aurora Scharff
Sep 17, 2026