Skip to content
~sami-ullah

Published Updated on 6 min read

Vue 3 Composition API Patterns I Use in Real Apps

The Vue 3 Composition API patterns worth learning first are small composables that own one concern, typed defineProps and defineEmits, a single async-data composable with loading and error state, provide/inject with typed keys, and explicit cleanup with onScopeDispose. Together they keep script setup components short, testable and easy to move between projects.

By · #vue #typescript

The Composition API patterns that pay off in Vue 3 are boring on purpose: one composable per concern, typed component contracts, one way to load data, typed injection keys and explicit cleanup. This post shows each pattern with code I would accept in a code review, tested against Vue 3.5 in November 2026.

I wrote it for developers who already know the basics of ref and computed and want to know how to structure a real app, not a counter demo.

What is the Composition API, in one paragraph?#

The Composition API is the set of functions (ref, reactive, computed, watch, lifecycle hooks) that let you declare component logic inside setup instead of spreading it across data, methods and computed options. With script setup, everything you declare at the top level is available to the template, and the compiler handles the boilerplate.

The real benefit is not the syntax. It is that logic for one feature can live together and then move out into a function you can reuse and test. The official Composition API FAQ explains the motivation well.

How should I structure a script setup component?#

Order the code the same way in every component: props and emits first, then injected dependencies, then state, then derived values, then actions, then lifecycle hooks. A predictable order matters more than which order you choose.

vue
<script setup lang="ts">
import { computed, ref } from "vue";

const props = defineProps<{
  title: string;
  items: { id: number; label: string; done: boolean }[];
}>();

const emit = defineEmits<{
  toggle: [id: number];
  clear: [];
}>();

const filter = ref<"all" | "open" | "done">("all");

const visible = computed(() =>
  props.items.filter((item) =>
    filter.value === "all" ? true : filter.value === "done" ? item.done : !item.done,
  ),
);

function toggle(id: number) {
  emit("toggle", id);
}
</script>

<template>
  <section>
    <h2>{{ title }}</h2>
    <select v-model="filter">
      <option value="all">All</option>
      <option value="open">Open</option>
      <option value="done">Done</option>
    </select>
    <ul>
      <li v-for="item in visible" :key="item.id">
        <label>
          <input type="checkbox" :checked="item.done" @change="toggle(item.id)" />
          {{ item.label }}
        </label>
      </li>
    </ul>
    <button type="button" @click="emit('clear')">Clear done</button>
  </section>
</template>

Two details are doing the work here. The type-only defineProps gives you checked props in templates and in parent components without runtime validators. The labelled tuple syntax for defineEmits (Vue 3.3 and later) makes the parent's @toggle handler receive a typed id.

If a component grows past roughly 150 lines of script, that is my signal to pull a composable out, not to add more sections.

What makes a good composable?#

A good composable owns one concern, takes its inputs as refs or getters, returns refs, and cleans up after itself. If you cannot name it with a short useSomething, it is probably doing two jobs.

Here is a composable for a debounced search input. It accepts a MaybeRefOrGetter so callers can pass a plain string, a ref or a getter, and it uses toValue (Vue 3.3+) to read it.

ts
// composables/useDebounced.ts
import { ref, watch, onScopeDispose, toValue, type MaybeRefOrGetter, type Ref } from "vue";

export function useDebounced<T>(source: MaybeRefOrGetter<T>, delayMs = 300): Ref<T> {
  const debounced = ref(toValue(source)) as Ref<T>;
  let timer: ReturnType<typeof setTimeout> | undefined;

  watch(
    () => toValue(source),
    (value) => {
      clearTimeout(timer);
      timer = setTimeout(() => {
        debounced.value = value;
      }, delayMs);
    },
  );

  onScopeDispose(() => clearTimeout(timer));

  return debounced;
}

Rules I follow for composables:

  1. Return an object of refs, not a reactive object, so callers can destructure without losing reactivity.
  2. Accept MaybeRefOrGetter for inputs that might change, and read them with toValue inside watch or computed.
  3. Register cleanup with onScopeDispose, which works both inside components and inside a manual effectScope.
  4. Keep side effects (fetching, timers, listeners) inside the composable, and keep formatting and display logic in the component.
  5. Never call a composable conditionally or inside an async callback after an await. Call it synchronously at the top of setup.

The last rule trips people up. Lifecycle hooks and onScopeDispose attach to the current active instance, and after an await in setup there may be no active instance any more. Vue warns about this in development, but only if you look at the console.

The symptom is easy to miss. The warning says a lifecycle hook or onScopeDispose was called with no active component instance or effect scope, and nothing else visibly breaks. The cost shows up later: the cleanup never registers, so a timer, listener or socket started by the composable keeps running after the component unmounts. If a composable needs data that arrives asynchronously, call it first and pass the data in as a ref that fills later.

How do I load async data with the Composition API?#

Write one small useAsync style composable and use it everywhere, so every screen handles loading, error and stale responses the same way. Without it, each component invents its own isLoading flags and forgets to cancel old requests.

ts
// composables/useFetchJson.ts
import { ref, shallowRef, watch, onScopeDispose, toValue, type MaybeRefOrGetter } from "vue";

export function useFetchJson<T>(url: MaybeRefOrGetter<string | null>) {
  const data = shallowRef<T | null>(null);
  const error = ref<Error | null>(null);
  const loading = ref(false);
  let controller: AbortController | null = null;

  async function run(target: string) {
    controller?.abort();
    controller = new AbortController();
    loading.value = true;
    error.value = null;
    try {
      const res = await fetch(target, { signal: controller.signal });
      if (!res.ok) throw new Error(`HTTP ${res.status} for ${target}`);
      data.value = (await res.json()) as T;
    } catch (err) {
      if ((err as Error).name !== "AbortError") error.value = err as Error;
    } finally {
      loading.value = false;
    }
  }

  watch(
    () => toValue(url),
    (target) => {
      if (target) void run(target);
    },
    { immediate: true },
  );

  onScopeDispose(() => controller?.abort());

  return { data, error, loading, refresh: () => toValue(url) && run(toValue(url)!) };
}

Using it in a component stays short:

vue
<script setup lang="ts">
import { computed, ref } from "vue";
import { useDebounced } from "@/composables/useDebounced";
import { useFetchJson } from "@/composables/useFetchJson";

type User = { id: number; name: string };

const query = ref("");
const debouncedQuery = useDebounced(query, 300);
const url = computed(() =>
  debouncedQuery.value ? `/api/users?q=${encodeURIComponent(debouncedQuery.value)}` : null,
);
const { data: users, loading, error } = useFetchJson<User[]>(url);
</script>

<template>
  <input v-model="query" type="search" aria-label="Search users" />
  <p v-if="loading">Loading…</p>
  <p v-else-if="error" role="alert">{{ error.message }}</p>
  <ul v-else>
    <li v-for="user in users ?? []" :key="user.id">{{ user.name }}</li>
  </ul>
</template>

I use shallowRef for data because API responses are usually replaced as a whole, not mutated deep inside. That avoids making every nested object reactive, which is measurable on large lists.

If you already use a library such as TanStack Query for Vue or Nuxt's useFetch, use that instead. The point is to have one pattern, not to write your own.

When should I use provide and inject?#

Use provide/inject when a value is needed by several levels of descendants inside one subtree, like a form context or a table's selection state. For app-wide state, a store is clearer. For a direct child, props are clearer.

Always use a typed InjectionKey, so the injected value is not unknown:

ts
// form/context.ts
import type { InjectionKey, Ref } from "vue";

export type FormContext = {
  disabled: Ref<boolean>;
  register: (name: string, validate: () => string | null) => void;
};

export const FormKey: InjectionKey<FormContext> = Symbol("FormContext");
ts
// form/useFormField.ts
import { inject } from "vue";
import { FormKey } from "./context";

export function useFormField(name: string, validate: () => string | null) {
  const form = inject(FormKey);
  if (!form) throw new Error("useFormField must be used inside a Form component");
  form.register(name, validate);
  return { disabled: form.disabled };
}

Throwing a clear error when the provider is missing saves a lot of time later. A silent undefined shows up three components away as "cannot read properties of undefined".

Ref or reactive: which should I default to?#

Default to ref for everything, and reach for reactive only for a local object you will never replace or destructure. That one rule avoids the most common reactivity bug in Vue 3.

Questionrefreactive
Works with primitivesYesNo, objects only
Safe to destructure from a composableYes, if you return the refsNo, loses reactivity unless you use toRefs
Can be replaced wholesaleYes, assign to .valueNo, reassigning breaks the link
Access in script.valueDirect property access
Access in templateAuto-unwrappedDirect
Good fitComposable return values, single values, API dataA local form model you mutate field by field

The Vue team now recommends ref as the primary API in the reactivity fundamentals guide, for the same reasons.

How do I share state between components without a store?#

Declare the state outside the composable function, at module level, and every caller will share the same refs. This is fine for small, client-only apps and a bad idea in server-side rendering, where module state is shared across requests.

ts
// composables/useSidebar.ts
import { ref, readonly } from "vue";

const open = ref(false);

export function useSidebar() {
  return {
    open: readonly(open),
    toggle: () => {
      open.value = !open.value;
    },
  };
}

Exposing a readonly ref and a toggle function keeps writes in one place. Once you need devtools inspection, SSR safety or more than a couple of shared values, move to Pinia. I compare the two in Pinia vs composables.

How do I test composables?#

Test composables directly, without mounting a component, by running them inside an effectScope and stopping the scope at the end. For composables that use lifecycle hooks such as onMounted, mount a tiny host component with Vue Test Utils instead.

ts
// composables/useDebounced.test.ts
import { effectScope, nextTick, ref } from "vue";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { useDebounced } from "./useDebounced";

beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());

it("updates only after the delay", async () => {
  const scope = effectScope();
  const source = ref("a");
  const debounced = scope.run(() => useDebounced(source, 200))!;

  source.value = "ab";
  await nextTick();
  expect(debounced.value).toBe("a");

  vi.advanceTimersByTime(200);
  expect(debounced.value).toBe("ab");

  scope.stop();
});

Because the cleanup uses onScopeDispose, scope.stop() clears the pending timer, and the test proves that the cleanup path works too.

Which Composition API mistakes are most common?#

The most common mistakes are losing reactivity, watching the wrong thing and doing too much inside watch. All three are quiet: the code runs, and the screen simply stops updating.

  1. Destructuring props. Before Vue 3.5, const { title } = defineProps() gave you a plain value that never updated. Vue 3.5 made destructured props reactive inside the compiler, but passing that destructured value into a composable still passes a snapshot. Pass a getter instead: useSomething(() => title).
  2. Destructuring a reactive object. Use toRefs or switch to ref.
  3. Watching a property directly. watch(state.count, ...) watches a number, not the property. Use watch(() => state.count, ...).
  4. Using watch to sync derived state. If a value can be computed from other values, use computed. A watch that copies one ref into another is usually a computed waiting to happen.
  5. Making large API responses deeply reactive. Use shallowRef and replace the whole value.

The reactivity API reference documents the exact behaviour of each function.

Where do these patterns show up in a real project?#

These patterns fit my concept builds, such as the Vue admin dashboard with role-based access and the Vue quiz app. The timed quizzes in the quiz app are the kind of feature where timer cleanup matters, and you can try them, with explanations and progress tracking, in my Vue quiz app demo. The admin dashboard is where typed injection keys and a shared data-loading composable matter most, because many screens list, filter and paginate the same kinds of records.

You can see the rest of my Vue work on the Vue skill page.

What should you do first?#

Pick the component in your codebase with the most ref declarations and the longest script setup block. Move its data loading into one useFetchJson style composable with abort and error handling, add onScopeDispose cleanup, and write one effectScope test for it. Once that pattern exists, every new screen can reuse it, and the next composable you extract will take half the time.

Frequently asked questions

Should I use the Composition API or the Options API in Vue 3?

Use the Composition API with script setup for new Vue 3 code. It gives better TypeScript inference, lets you group code by feature instead of by option type, and makes logic reusable through composables. The Options API is still supported and fine for small components or teams migrating from Vue 2, so there is no need to rewrite working components just to switch.

What is a composable in Vue 3?

A composable is a plain function, usually named with a use prefix, that calls Vue reactivity APIs such as ref, computed and watch and returns reactive state or functions. It lets several components share stateful logic without mixins. Each call creates fresh state unless the state is declared outside the function, which turns it into shared module-level state.

When should I use ref instead of reactive?

Default to ref. It works for primitives and objects, survives destructuring when you return it from a composable, and makes reactive values obvious because you read them through .value in script code. Use reactive only for a local object you never replace or destructure, such as a form model, because reassigning or destructuring a reactive object breaks the reactive connection.

How do I avoid memory leaks in Vue 3 composables?

Clean up anything the composable starts that Vue does not track itself: event listeners, intervals, WebSocket connections and AbortControllers for fetch calls. Register the cleanup with onScopeDispose or onUnmounted inside the composable. Watchers and computed values created during setup are stopped automatically when the component unmounts, so you only need to handle browser and network resources manually.

Where I used this

Keep reading

#nextjs6 min

JSON-LD Structured Data in Next.js 15

How to add JSON-LD structured data in Next.js 15: a safe script component, typed schema-dts graphs with stable @id values, FAQ rules and how to test it.

read →