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