Vue 3 and Nuxt · Vue 3 Fundamentals

Composables: Reusable Stateful Logic

Extract reusable stateful logic into composables that return refs, accept reactive inputs, and clean up after themselves with effect scopes.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

Two composables run in Node: useSearch, which debounces a reactive query and tracks loading, results and errors, and usePolling, which starts a timer and cleans it up when its effect scope stops (as it would when a component unmounts).

The idea

  • A composable is a function (useX) that uses reactivity APIs and returns refs, so components share logic without mixins.
  • Accept refs or getters as inputs (toValue() reads either) and react to changes with watch.
  • Register cleanup with onScopeDispose (or onUnmounted in components); every component setup runs inside an effect scope that stops on unmount, which also stops watchers.
  • Return refs, not reactive objects, so callers can destructure.

The code

A debounced search composable and cleanup on scope stop
import { ref, watch, toValue, effectScope, onScopeDispose } from "vue";

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const catalogue = ["Mug", "Teapot", "Tea strainer", "Kettle"];
async function searchApi(q) {
  console.log(`  api called with "${q}"`);
  await sleep(20);
  if (q === "error") throw new Error("search service unavailable");
  return catalogue.filter((name) => name.toLowerCase().includes(q.toLowerCase()));
}

function useSearch(query, { delay = 50 } = {}) {
  const results = ref([]);
  const loading = ref(false);
  const error = ref(null);
  let timer;
  watch(() => toValue(query), (q) => {
    clearTimeout(timer);
    timer = setTimeout(async () => {
      loading.value = true;
      error.value = null;
      try { results.value = await searchApi(q); }
      catch (e) { error.value = e.message; results.value = []; }
      finally { loading.value = false; }
    }, delay);
  });
  onScopeDispose(() => clearTimeout(timer));
  return { results, loading, error };
}

function usePolling(callback, ms) {
  const id = setInterval(callback, ms);
  onScopeDispose(() => { clearInterval(id); console.log("polling stopped"); });
}

const scope = effectScope();                            // a component's setup runs in a scope like this
const query = ref("");
let polls = 0;
const { results, loading, error } = scope.run(() => {
  usePolling(() => polls++, 10);                       // composables are called synchronously in setup
  return useSearch(query);
});

query.value = "t"; query.value = "te"; query.value = "tea";   // typing quickly: one API call
await sleep(120);
console.log("results:", results.value, "loading:", loading.value);
query.value = "error";
await sleep(120);
console.log("error:", error.value);
console.log("polling ran:", polls > 10 ? "yes" : "no");
scope.stop();                                           // like unmounting the component

Output

  api called with "tea"
results: [ 'Teapot', 'Tea strainer' ] loading: false
  api called with "error"
error: search service unavailable
polling ran: yes
polling stopped

Run this on your computer — see “Get Started” for setup.

Common mistakes

  • Starting timers, listeners or subscriptions in a composable without cleaning them up, leaking work after unmount.
  • Returning a reactive object, which callers can't destructure without losing reactivity.
  • Calling composables conditionally, outside setup, or after an await in setup: there's no active scope or component instance then, so their cleanup is never registered.
  • Writing composables that fetch on the server and again in the browser in SSR apps; in Nuxt use useFetch/useAsyncData.

Key points

  • Composables return refs and accept refs or getters.
  • Debounce and handle loading and errors inside the composable.
  • Clean up with onScopeDispose so unmounting stops all work.

Further reading: Composables · effectScope

Practice questions

Why should composables return refs instead of a reactive object?

Answer

Callers usually destructure the result; refs stay reactive when destructured, properties of a reactive object don't.

How does a composable clean up timers or listeners?

Answer

By registering cleanup with onScopeDispose (or onUnmounted); when the component unmounts its effect scope stops and the cleanup runs.

What does toValue do in a composable?

Answer

It normalizes an input that may be a plain value, a ref or a getter into its current value, so composables accept all three.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?