React

Reusable composables in Vue 3: contract, tests and limits

A reusable composable follows three rules: dependencies passed as arguments, state created inside the call, cleanup registered in hooks. The rest is style.

Key points

  • A composable that creates state inside the function shares nothing between two calls.
  • Pass dependencies as refs or getters instead of reading a global module.
  • Cleanup in onUnmounted or onCleanup avoids orphan requests and listeners.

A reusable composable is a function that takes its dependencies as arguments, creates fresh state on every call and registers cleanup in Vue hooks. Meet those three rules and it moves between projects unchanged.

The minimum contract

// composables/useLocalStorage.ts
import { ref, watch } from "vue";

export function useLocalStorage(key: string, initial: T) {
  const stored = localStorage.getItem(key);
  const state = ref(stored ? JSON.parse(stored) : initial);

  watch(
    state,
    (value) => localStorage.setItem(key, JSON.stringify(value)),
    { deep: true },
  );

  return state;
}

The key comes from outside, the state is born inside. Two components calling useLocalStorage("theme", "light") hold two distinct refs that read the same key: right for a local preference, wrong for a cart.

Explicit dependencies

import { ref, watch } from "vue";

export function useDebounced(source: () => T, delay = 300) {
  const value = ref(source());
  let timer: ReturnType | undefined;

  watch(source, (next) => {
    clearTimeout(timer);
    timer = setTimeout(() => (value.value = next), delay);
  });

  return value;
}

// usage: useDebounced(() => query.value)

A getter like () => query.value keeps the composable independent of the variable name. Reading an imported module ref instead would force the test to rebuild that module and break reuse across projects.

Testing without mounting a component

import { describe, expect, it } from "vitest";
import { useLocalStorage } from "./useLocalStorage";

describe("useLocalStorage", () => {
  it("reads the stored value", () => {
    localStorage.setItem("theme", JSON.stringify("dark"));
    expect(useLocalStorage("theme", "light").value).toBe("dark");
  });
});

As long as the composable uses only ref, computed and watch, the test is a plain function call. Once onMounted or onUnmounted appear you need a component context, and the test moves to @vue/test-utils.

Composable or store?

CaseWinnerWhy
Formatting a dateComposableA pure function, no shared state
A listener on a media queryComposableState local to the view that uses it
The signed-in userPiniaThe whole app needs a single instance
State created outside the functionPiniaA shared module is an undeclared store

The limitation to plan for

A composable has no guaranteed initialisation order against the others: if two functions read the same localStorage key at mount, the last writer wins. For state that must exist once, the answer is a store, not a composable with a module-level ref.

Where to go next

In the catalogue: shadcn-vue, PrimeVue and BootstrapVue Next; components with local state are in components. The guides: Composition API and Pinia. The comparison: the rules of hooks in React.

Frequently asked questions

Can a composable create shared state?

Yes, but only when the state is created outside the function, at module level. Called inside the function, every call creates fresh state.

Where should the composable live?

In a composables folder, with a name starting with use. The name is not magic: it tells a reader the function uses the Composition API.

How do I test it without mounting a component?

Call it inside an effect or a minimal component context. Plain refs need only Vitest; lifecycle hooks need a component.

Composable or Pinia?

Composable for reused logic with no shared state, Pinia for state that survives navigation. If the value must exist once, you need the store.

Why does the cleanup never run?

If the function is called outside setup, onUnmounted has no instance. Pass an AbortController and abort it in the watch cleanup.