100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
JavaScript

Writing a Custom Composable

A hands-on walkthrough of designing, implementing, and consuming a custom composable, using a realistic data-fetching example.

The Composition API in DepthIntermediate10 min readJul 9, 2026
Analogies

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.

javascript
// 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.

vue
<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

Was this page helpful?

Topics covered

#JavaScript#VueJsStudyNotes#WebDevelopment#WritingACustomComposable#Writing#Custom#Composable#Designing#StudyNotes#SkillVeris