All writing
8 min read

Composables That Scale: Reusable Logic in Vue 3

The Composition API gives you freedom — and the freedom to make a mess. Here are the conventions I use to keep composables predictable as a codebase grows.

VueComposition APIArchitecture

When I led the move to Vue 3 at Khanoumi, the Composition API was the feature everyone was excited about and nobody had conventions for. Six months in, we had composables that returned refs, composables that mutated shared state, and composables that quietly started intervals you could never clean up. Here is what we standardized on.

A composable is a function, not a mixin

The whole point of moving off mixins is explicitness. A composable should take inputs as arguments and return everything it exposes. No implicit this, no merged option soup. The shape I insist on:

ts
import { ref, onUnmounted } from "vue";

export function useInterval(callback: () => void, delayMs: number) {
  const isRunning = ref(false);
  let id: ReturnType<typeof setInterval> | null = null;

  function start() {
    if (id !== null) return;
    isRunning.value = true;
    id = setInterval(callback, delayMs);
  }

  function stop() {
    if (id === null) return;
    clearInterval(id);
    id = null;
    isRunning.value = false;
  }

  // Always clean up after yourself.
  onUnmounted(stop);

  return { isRunning, start, stop };
}

Notice three things: it returns a plain object, it owns its own cleanup via onUnmounted, and the consumer decides when to start. A composable that starts side effects on creation is a composable that will leak.

Return refs, accept getters

The most common reactivity bug I reviewed was passing props.userId into a composable and watching it never update. Primitives are passed by value — by the time the composable reads it, the binding is gone. Accept a getter instead, and use toValue to support both plain values and reactive sources:

ts
import { ref, watchEffect, toValue, type MaybeRefOrGetter } from "vue";

export function useUser(id: MaybeRefOrGetter<string>) {
  const user = ref<User | null>(null);
  const loading = ref(false);

  watchEffect(async () => {
    loading.value = true;
    user.value = await fetchUser(toValue(id));
    loading.value = false;
  });

  return { user, loading };
}
vue
<script setup lang="ts">
const props = defineProps<{ userId: string }>();

// Pass a getter so the composable tracks changes reactively.
const { user, loading } = useUser(() => props.userId);
</script>

Shared state vs. instance state

This is the distinction that trips people up. State declared inside the composable function is per-call — each component gets its own. State declared in module scope is shared across every consumer. Both are valid; you just have to be deliberate.

ts
import { reactive } from "vue";

// Module scope = one instance shared everywhere it is imported.
const session = reactive({ token: "", user: null as User | null });

export function useSession() {
  function login(token: string) {
    session.token = token;
  }
  return { session, login };
}

I use module-scoped state for genuinely global concerns — auth, feature flags, a toast queue — and per-call state for everything else. If you find yourself reaching for a store library to do what a module-scoped composable already does, pause and reconsider.

Naming and boundaries

  • Prefix with use — it signals reactivity rules to readers and tooling.
  • One responsibility per composable. useUser fetches a user; it does not also manage a modal.
  • Return readonly refs when consumers should not mutate state directly — readonly(state) makes intent enforceable.
  • Keep composables framework-pure: no direct DOM queries that assume a single mount, no global event listeners without cleanup.
A good composable reads like a small, honest contract: here is what I need, here is what I give you, and I clean up when you are done with me.

Get these conventions in place early and the Composition API delivers on its promise — logic you can actually extract, test, and reuse, instead of the mixin tangle it was meant to replace.