Skip to content
TutorialData & APIs

A reusable useFetch composable, written by hand

Every component that loads data needs the same three refs and the same cleanup. Put them in one composable, and you will understand what the library versions do for you.

vuejs.school team17 Apr 20256 min readVue 3.5

A component that loads data from an API needs a place for the result, a place for the error, a loading flag, and a way to load again when its input changes without letting an old response overwrite a new one. Written by hand in every component, that code comes out slightly different each time.

You can write it once instead, as a useFetch(url) composable in plain Vue with no library: it refetches when the URL changes and cancels the request it no longer needs. VueUse and Nuxt each ship a fuller useFetch for real projects. Building the small one yourself shows you what theirs do for you.

Start with three refs and one request

A composable is a function that uses the Composition API and returns reactive state. The first version takes a string and fetches it once:

src/composables/useFetch.ts
import { ref, shallowRef } from 'vue'

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

  async function load() {
    loading.value = true
    error.value = null
    try {
      const response = await fetch(url)
      if (!response.ok) throw new Error(`Request failed with status ${response.status}`)
      data.value = await response.json()
    } catch (e) {
      error.value = e instanceof Error ? e : new Error(String(e))
    } finally {
      loading.value = false
    }
  }

  load()

  return { data, error, loading }
}

Two choices here are deliberate:

  • data and error are shallowRefs rather than refs. A shallowRef reacts only when you assign a new value to .value, and it does not wrap every nested property of the payload in a proxy. The composable always replaces the whole response and never mutates inside it, so deep tracking would be wasted work on a large JSON body.
  • The response.ok check is there because fetch rejects only when the request itself fails, on a network error for example. A 404 or a 500 resolves normally. Without the check, a JSON error body would land in data as if it were the result, and an HTML error page would fail in response.json() with a confusing parse error.

Refetch when the URL changes

The composable should load again when the URL changes, which means the URL has to be something reactive. Vue has a type for "a plain value, a ref, or a getter (a function that returns the value)": MaybeRefOrGetter. And it has one function that reads all three: toValue(), added in 3.3, which returns a plain value as is, a ref's .value, or the result of calling a getter.

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

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

  async function load(target: string) {
    // same body as before, with fetch(target)
  }

  watchEffect(() => {
    load(toValue(url))
  })

  return { data, error, loading }
}

watchEffect runs its function immediately and tracks every reactive value read during that synchronous run. The important line is toValue(url) inside the effect. What it registers depends on what the caller passed:

  • A ref: the ref is registered, so assigning a new URL to it fetches again.
  • A getter that reads props.id: that prop is registered.
  • A plain string: nothing is registered, and the effect runs exactly once.

The call to load is not awaited, on purpose. The effect only needs to start the request, and everything it should track has been read by the time load is called.

Abort the request nobody wants any more

Now the race. The URL changes from /users/1/posts to /users/2/posts while the first request is in flight. Both responses arrive, in whichever order the network decides, and the last one to arrive wins. If that is the first one, the component shows user 1's posts under user 2's name.

The browser's tool for this is AbortController. You pass its signal to fetch, and calling abort() makes that fetch reject. Vue's tool for knowing when to abort is onWatcherCleanup(), added in 3.5. You call it inside a watcher with a cleanup function, and Vue runs that function just before the watcher runs again and when the watcher stops.

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

export function useFetch(url: MaybeRefOrGetter<string>) {
  const data = shallowRef<unknown>(null)
  const error = shallowRef<Error | null>(null)
  const loading = ref(false)
  let controller: AbortController | undefined

  async function load(target: string) {
    controller?.abort()
    controller = new AbortController()
    const { signal } = controller

    loading.value = true
    error.value = null
    try {
      const response = await fetch(target, { signal })
      if (!response.ok) throw new Error(`Request failed with status ${response.status}`)
      data.value = await response.json()
    } catch (e) {
      if (signal.aborted) return
      error.value = e instanceof Error ? e : new Error(String(e))
    } finally {
      if (!signal.aborted) loading.value = false
    }
  }

  watchEffect(() => {
    load(toValue(url))
    onWatcherCleanup(() => controller?.abort())
  })

  return { data, error, loading }
}

What the new lines do:

  • load now begins by aborting whatever request came before it, so an older request can no longer overwrite the result of a newer one.
  • The signal.aborted checks make an aborted request leave quietly. Its rejection is not an error the user should see, and it must not switch loading off while the newer request is still running.
  • The onWatcherCleanup line covers the case load cannot: when the component unmounts, Vue stops the effect, the cleanup runs, and the in-flight request is cancelled instead of writing to refs nobody is watching.

onWatcherCleanup is called synchronously inside the effect, before any await. That is the one rule it has: after an await, Vue no longer knows which watcher the cleanup belongs to.

Add refresh and a type parameter

Two more things make this usable. A component needs to reload on demand, after a failed request or after the user changed something on the server, so the composable returns a refresh function. And data should not be unknown: the caller knows what the endpoint returns, so let it say so with a generic type parameter.

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)
  let controller: AbortController | undefined

  async function load(target: string) {
    controller?.abort()
    controller = new AbortController()
    const { signal } = controller

    loading.value = true
    error.value = null
    try {
      const response = await fetch(target, { signal })
      if (!response.ok) throw new Error(`Request failed with status ${response.status}`)
      data.value = (await response.json()) as T
    } catch (e) {
      if (signal.aborted) return
      error.value = e instanceof Error ? e : new Error(String(e))
    } finally {
      if (!signal.aborted) loading.value = false
    }
  }

  function refresh() {
    return load(toValue(url))
  }

  watchEffect(() => {
    load(toValue(url))
    onWatcherCleanup(() => controller?.abort())
  })

  return { data, error, loading, refresh }
}

useFetch<Post[]>(...) now returns data typed as ShallowRef<Post[] | null>, which is what a template needs: check for null, then use it as a list. The cast on response.json() is honest about what is happening. fetch cannot know the shape of the body, so the type is a promise you make to the compiler, and nothing checks it at runtime. If you need that guarantee, validate the body with a schema library before you assign it.

refresh reuses load and reads the URL through toValue at call time, so it always reloads the URL the component is showing. Because load aborts the previous controller, pressing a refresh button twice quickly leaves only the second request running.

Use it in a component

This component lists a user's posts from JSONPlaceholder, a free fake API. @/ is the alias for src/ that create-vue sets up.

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

interface Post {
  id: number
  title: string
}

const { userId } = defineProps<{ userId: number }>()

const { data: posts, error, loading, refresh } = useFetch<Post[]>(
  () => `https://jsonplaceholder.typicode.com/users/${userId}/posts`,
)
</script>

<template>
  <p v-if="loading">Loading posts...</p>
  <p v-else-if="error">
    {{ error.message }}
    <button type="button" @click="refresh">Try again</button>
  </p>
  <ul v-else-if="posts">
    <li v-for="post in posts" :key="post.id">{{ post.title }}</li>
  </ul>
</template>

The URL is passed as a getter. userId is a destructured prop, which the Vue 3.5 compiler turns into props.userId, so reading it inside the getter is tracked, and the component fetches again whenever the parent changes the prop. Nothing in the component mentions aborting, race conditions or state resets; the composable owns all of it.

Destructuring the result into posts, error, loading and refresh works because the composable returns an object of separate refs rather than one reactive object. That is the convention the Vue docs recommend for composables.

To see the abort, open the Network tab in Chrome's DevTools, turn on throttling, and have the parent change userId twice quickly. The request you switched away from shows (canceled).

What VueUse and Nuxt add

This useFetch is under forty lines. The library versions start from the same shape and add what you would otherwise write next:

  • VueUse's useFetch has immediate: false for loading on demand, request and response interceptors, a timeout, typed helpers such as .json(), and an execute function that plays the role of refresh. One difference from ours: it refetches when the URL changes only if you pass refetch: true.
  • Nuxt's useFetch works differently: it runs during server-side rendering, sends the result to the browser in the page payload so the client does not fetch again, and deduplicates requests by key across components.

Both are built around toValue, watchers and AbortController, which is why building the small one by hand is worth an afternoon. The next time one of them behaves in a way you did not expect, you will know which of these lines it is coming from.

Check it stuck3 questions from the A reusable fetch composable quiz.
Practice now
Was this helpful?

Keep reading