A Reference to Composable Patterns in Vue

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

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.

Pattern: Return an Object of Refs

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.

Pattern: Accept MaybeRefOrGetter Arguments

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.

Pattern: The Async Composable with State

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);

Pattern: Self-Managing Lifecycle

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.

Pattern: Shared vs Per-Instance State

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 useState for shared state instead. In a client-only app, module scope is fine.

Pattern: Connected vs Presentational Composables

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.

Pattern: Return a Consistent, Renameable Shape

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.

Pattern: Options Object for Configurable Composables

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 });

Key Takeaways

  • Return an object of refs so consumers can destructure without losing reactivity
  • Accept MaybeRefOrGetter arguments and normalize with toValue so composables work with plain values, refs, and getters
  • For async composables, return data, loading, error, and an execute function, and don't fetch automatically on creation
  • Let composables manage their own lifecycle so cleanup is automatic for the consumer
  • Choose per-instance (state in the function) or shared (state in module scope) deliberately, and use useState for shared state in SSR
  • Separate connected composables (side effects) from presentational ones (pure logic) for testability
  • Rename returned values at the boundary to give consumers a clear API
  • Use an options object once you have more than a couple of optional settings

Conclusion

These 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.

More certificates.dev articles

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.