Skip to content
DasPeTe Tutorials

Your First Vue 3 Composables

Learn to pull reusable logic out of your components, starting with a counter and ending with a useFetch that cancels outdated requests.

Quest objectives

  • Understand what makes a function a composable
  • Extract stateful logic out of a component
  • Keep a ref in sync with localStorage
  • Write a useFetch that cancels outdated requests

Components are great at reusing markup, but sooner or later you'll want to reuse logic: a timer, a form's validation, a data fetch. In Vue 3, that's the job of a composable. A composable is a plain function that uses the Composition API and returns reactive state. By convention, its name starts with use.

Start with a component

Here is a component with a little bit of logic in it:

src/components/Counter.vue
<script setup lang="ts">
import { computed, ref } from 'vue';

const count = ref(0);
const double = computed(() => count.value * 2);

function increment() {
  count.value += 1;
}
</script>

<template>
  <button @click="increment">{{ count }} (double: {{ double }})</button>
</template>

Now say a second component needs the same counter. Instead of copying it, move the logic into its own file. Nothing about it has to change, because ref and computed work outside of components as well:

src/composables/useCounter.ts
import { computed, ref } from 'vue';

export function useCounter(initial = 0) {
  const count = ref(initial);
  const double = computed(() => count.value * 2);

  function increment() {
    count.value += 1;
  }

  return { count, double, increment };
}

The component gets much smaller. It only says what it needs:

src/components/Counter.vue
<script setup lang="ts">
import { useCounter } from '../composables/useCounter';

const { count, double, increment } = useCounter(10);
</script>

<template>
  <button @click="increment">{{ count }} (double: {{ double }})</button>
</template>

Connect to the outside world

Composables become really useful when they wrap something that isn't reactive by nature. localStorage is a good example: this ref remembers its value across page reloads.

src/composables/useStoredRef.ts
import { ref, watch, type Ref } from 'vue';

export function useStoredRef<T>(key: string, initial: T): Ref<T> {
  const stored = localStorage.getItem(key);
  const value = ref(stored === null ? initial : JSON.parse(stored)) as Ref<T>;

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

  return value;
}

You use it just like a normal ref, for example with const theme = useStoredRef('theme', 'dark'). Any change is saved automatically, and { deep: true } also picks up changes inside objects and arrays.

A useFetch that cleans up after itself

Here is a classic bug: the user clicks quickly, two requests start, and the slower first response overwrites the newer one. The fix is to cancel the old request when the URL changes.

watchEffect re-runs whenever a reactive value it reads changes. onWatcherCleanup(), available since Vue 3.5, registers a function that runs before the next re-run, which is the perfect spot to abort. toValue() lets the composable accept a plain string, a ref or a getter.

src/composables/useFetch.ts
import {
  onWatcherCleanup,
  ref,
  shallowRef,
  toValue,
  watchEffect,
  type MaybeRefOrGetter,
} from 'vue';

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

  async function load(target: string, signal: AbortSignal) {
    loading.value = true;
    error.value = null;

    try {
      const response = await fetch(target, { signal });
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      data.value = (await response.json()) as T;
      loading.value = false;
    } catch (caught) {
      // A newer request took over, and it is the one that ends the loading state.
      if (signal.aborted) return;
      error.value = caught as Error;
      loading.value = false;
    }
  }

  watchEffect(() => {
    const controller = new AbortController();
    onWatcherCleanup(() => controller.abort());
    load(toValue(url), controller.signal);
  });

  return { data, error, loading };
}

Pass a getter, and the composable refetches whenever a value in it changes:

src/components/UserCard.vue
<script setup lang="ts">
import { ref } from 'vue';
import { useFetch } from '../composables/useFetch';

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

const id = ref(1);
const { data: user, error, loading } = useFetch<User>(
  () => `https://jsonplaceholder.typicode.com/users/${id.value}`,
);
</script>

<template>
  <button @click="id += 1">Next user</button>
  <p v-if="loading">Loading...</p>
  <p v-else-if="error">{{ error.message }}</p>
  <p v-else-if="user">{{ user.name }}</p>
</template>

Three rules of thumb

  • Call composables at the top of <script setup>, not inside callbacks or conditions, so that Vue can tie them to the current component.

  • Return plain objects of refs. That way, destructuring keeps them reactive.

  • Clean up what you start: timers, event listeners and requests.

Comments

The comments are GitHub Discussions: sign in with GitHub to write one.

The comments come from GitHub, through giscus. Loading them sends your IP address to giscus.app and GitHub.