Writing a Custom Composable
Understanding composables conceptually is one thing; writing a genuinely useful one requires thinking about API design — what state to expose, what parameters to accept, and how to handle edge cases like race conditions or cleanup. This topic walks through building useFetch, a composable that wraps an async data request with loading and error state, a pattern common enough that most non-trivial Vue apps end up writing something like it early on.
Cricket analogy: Knowing the rules of cricket is one thing; designing a genuinely useful training drill — what skills to isolate, how to handle a rain interruption — is another, just as useFetch requires deciding what state to expose and how to handle a request that gets cancelled mid-flight.
Designing the state shape
A good data-fetching composable typically exposes at least three pieces of reactive state: the resolved data, a loading boolean, and an error object (or null). Consumers destructure exactly what they need, and the composable itself owns the logic for transitioning between these states as the request progresses.
Cricket analogy: A good data-fetching composable is like a scoreboard operator tracking three things at once: the current score (data), whether play is paused for a review (loading), and whether the umpire flagged an error (error) — spectators just read whichever one they need.
// composables/useFetch.js
import { ref, watchEffect, toValue } from 'vue'
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const loading = ref(false)
watchEffect(async () => {
data.value = null
error.value = null
loading.value = true
try {
const response = await fetch(toValue(url))
if (!response.ok) throw new Error(`HTTP ${response.status}`)
data.value = await response.json()
} catch (err) {
error.value = err
} finally {
loading.value = false
}
})
return { data, error, loading }
}Accepting reactive arguments
A well-designed composable should accept its input as a plain value, a ref, or a getter function, and normalize it internally with toValue() (Vue 3.3+). This lets useFetch(someRef) automatically re-run whenever someRef changes — because it's read inside watchEffect, which tracks it as a dependency — while useFetch('https://static-url.com') still works for a plain string that never changes.
Cricket analogy: A well-designed composable is like a scoring app that accepts either a fixed final score, a live feed reference, or a function computing the current run rate, normalizing all three with toValue() so it re-updates automatically when the live feed changes but still works for a completed match's fixed score.
<script setup>
import { ref } from 'vue'
import { useFetch } from '@/composables/useFetch'
const userId = ref(1)
const { data: user, loading, error } = useFetch(
() => `/api/users/${userId.value}`
)
</script>
<template>
<p v-if="loading">Loading...</p>
<p v-else-if="error">Failed to load user.</p>
<p v-else>{{ user?.name }}</p>
</template>Handling cleanup and race conditions
Because watchEffect re-runs whenever its dependencies change, a fast-changing userId could trigger overlapping requests where an older, slower response resolves after a newer one, overwriting fresh data with stale data. A robust composable guards against this — for example by capturing a local flag inside the effect and checking it hasn't been invalidated before committing the response — and effects should also be cleaned up automatically when the component unmounts, which watchEffect already does for you.
Cricket analogy: Like a scoreboard operator who submits a review request for one delivery, then a faster review for a later delivery comes back first and overwrites the correct result with a stale one — a robust composable flags each request so an old, slow response can't clobber the newer answer.
toValue(), added in Vue 3.3, is the Composition API's normalization helper: it unwraps refs, calls getter functions, and passes plain values through unchanged. It is directly analogous to how VueUse (the popular composables library) designed its own utilities to accept 'MaybeRefOrGetter' arguments, and toValue formalizes that pattern into Vue core.
Forgetting to guard against race conditions in a fetch-style composable is a very common real-world bug: without a cancellation or staleness check, rapidly changing inputs (like a search-as-you-type field) can cause an earlier, slow response to overwrite the result of a later, faster one, displaying incorrect data to the user.
- A well-designed composable exposes clear reactive state — like data, loading, and error — for a specific concern.
- Composables that accept dynamic input should normalize plain values, refs, or getters with toValue().
- watchEffect automatically re-runs when a reactive dependency it reads changes, and cleans itself up on unmount.
- Race conditions are a real risk in async composables; guard against stale responses overwriting fresh ones.
- Returning refs directly (data, error, loading) lets consuming components destructure safely without losing reactivity.
- VueUse is a large ecosystem of pre-built composables following these same design conventions.
Practice what you learned
1. In the useFetch example, what three pieces of state does the composable expose?
2. What does toValue() do in a composable that accepts a plain value, ref, or getter function?
3. Why does using watchEffect inside useFetch let it automatically refetch when its URL argument changes?
4. What real-world bug can occur in an async composable without race-condition handling?
5. What automatically happens to a watchEffect's side effect when the owning component unmounts?
Was this page helpful?
You May Also Like
Composables Explained
Understand what composables are, why they replace mixins as Vue's primary reuse pattern, and the conventions they follow.
Fetching Data in Vue
Learn common patterns for fetching data in Vue components and composables, including lifecycle timing, reactivity pitfalls, and race conditions.
Handling Loading and Error States
Explore patterns for modeling async status explicitly in Vue components so templates can reliably render loading, error, empty, and success UI.
Watchers Explained
How watch() and watchEffect() let you run side effects in response to reactive state changes, and how to choose between them.
Related Reading
Related Study Notes in Web Development
Browse all study notesWebSockets Study Notes
Web Development · 30 topics
Web DevelopmentWebAssembly Study Notes
WebAssembly · 30 topics
Web DevelopmentgRPC Study Notes
Protocol Buffers · 30 topics
Web DevelopmentSpring Boot Study Notes
Java · 30 topics
Web DevelopmentFlask Study Notes
Python · 30 topics
Web DevelopmentDjango Study Notes
Python · 30 topics