---
title: "A reusable useFetch composable, written by hand · vuejs.school"
description: "Build useFetch(url) in plain Vue step by step: loading and error refs, a reactive url, request cancellation, a refresh function and a generic type parameter."
canonical_url: "https://vuejs.school/community/blog/a-reusable-usefetch-composable-by-hand"
meta:
  "og:description": "Build useFetch(url) in plain Vue step by step: loading and error refs, a reactive url, request cancellation, a refresh function and a generic type parameter."
  "og:image": "https://vuejs.school/_og/d/a_~QSByZXVzYWJsZSB1c2VGZXRjaCBjb21wb3NhYmxlLCB3cml0dGVuIGJ5IGhhbmQgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~QSByZXVzYWJsZSB1c2VGZXRjaCBjb21wb3NhYmxlLCB3cml0dGVuIGJ5IGhhbmQ%2Cdescription_~QnVpbGQgdXNlRmV0Y2godXJsKSBpbiBwbGFpbiBWdWUgc3RlcCBieSBzdGVwOiBsb2FkaW5nIGFuZCBlcnJvciByZWZzLCBhIHJlYWN0aXZlIHVybCwgcmVxdWVzdCBjYW5jZWxsYXRpb24sIGEgcmVmcmVzaCBmdW5jdGlvbiBhbmQgYSBnZW5lcmljIHR5cGUgcGFyYW1ldGVyLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hLXJldXNhYmxlLXVzZWZldGNoLWNvbXBvc2FibGUtYnktaGFuZCI%2Cs_CpuBSJLaTkFcOQrB.png"
  "og:image:alt": "A reusable useFetch composable, written by hand · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "A reusable useFetch composable, written by hand · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~QSByZXVzYWJsZSB1c2VGZXRjaCBjb21wb3NhYmxlLCB3cml0dGVuIGJ5IGhhbmQgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~QSByZXVzYWJsZSB1c2VGZXRjaCBjb21wb3NhYmxlLCB3cml0dGVuIGJ5IGhhbmQ%2Cdescription_~QnVpbGQgdXNlRmV0Y2godXJsKSBpbiBwbGFpbiBWdWUgc3RlcCBieSBzdGVwOiBsb2FkaW5nIGFuZCBlcnJvciByZWZzLCBhIHJlYWN0aXZlIHVybCwgcmVxdWVzdCBjYW5jZWxsYXRpb24sIGEgcmVmcmVzaCBmdW5jdGlvbiBhbmQgYSBnZW5lcmljIHR5cGUgcGFyYW1ldGVyLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hLXJldXNhYmxlLXVzZWZldGNoLWNvbXBvc2FibGUtYnktaGFuZCI%2Cs_CpuBSJLaTkFcOQrB.png"
  "twitter:image:alt": "A reusable useFetch composable, written by hand · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## 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 `shallowRef`s rather than `ref`s. 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 stuck**3 questions from the A reusable fetch composable quiz.

[**Practice now**](https://vuejs.school/learn/data/a-reusable-usefetch-composable?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Deep dive · 6 min read**Streaming AI responses: fetch, ReadableStream and the AI SDK**](https://vuejs.school/community/blog/streaming-ai-responses-fetch-readablestream-and-the-ai-sdk) [Tutorial · 8 min read**Authentication basics: tokens, cookies and where to keep them**](https://vuejs.school/community/blog/authentication-basics-tokens-cookies-and-where-to-keep-them)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/a-reusable-usefetch-composable-by-hand#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-04-17","description":"Build useFetch(url) in plain Vue step by step: loading and error refs, a reactive url, request cancellation, a refresh function and a generic type parameter.","name":"A reusable useFetch composable, written by hand","url":"https://vuejs.school/community/blog/a-reusable-usefetch-composable-by-hand","breadcrumb":{"@id":"https://vuejs.school/community/blog/a-reusable-usefetch-composable-by-hand#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/a-reusable-usefetch-composable-by-hand"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/a-reusable-usefetch-composable-by-hand#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-04-17","description":"Build useFetch(url) in plain Vue step by step: loading and error refs, a reactive url, request cancellation, a refresh function and a generic type parameter.","headline":"A reusable useFetch composable, written by hand","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~QSByZXVzYWJsZSB1c2VGZXRjaCBjb21wb3NhYmxlLCB3cml0dGVuIGJ5IGhhbmQgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~QSByZXVzYWJsZSB1c2VGZXRjaCBjb21wb3NhYmxlLCB3cml0dGVuIGJ5IGhhbmQ%2Cdescription_~QnVpbGQgdXNlRmV0Y2godXJsKSBpbiBwbGFpbiBWdWUgc3RlcCBieSBzdGVwOiBsb2FkaW5nIGFuZCBlcnJvciByZWZzLCBhIHJlYWN0aXZlIHVybCwgcmVxdWVzdCBjYW5jZWxsYXRpb24sIGEgcmVmcmVzaCBmdW5jdGlvbiBhbmQgYSBnZW5lcmljIHR5cGUgcGFyYW1ldGVyLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hLXJldXNhYmxlLXVzZWZldGNoLWNvbXBvc2FibGUtYnktaGFuZCI%2Cs_CpuBSJLaTkFcOQrB.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/a-reusable-usefetch-composable-by-hand#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/a-reusable-usefetch-composable-by-hand#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/a-reusable-usefetch-composable-by-hand#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"A reusable useFetch composable, written by hand","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~QSByZXVzYWJsZSB1c2VGZXRjaCBjb21wb3NhYmxlLCB3cml0dGVuIGJ5IGhhbmQgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~QSByZXVzYWJsZSB1c2VGZXRjaCBjb21wb3NhYmxlLCB3cml0dGVuIGJ5IGhhbmQ%2Cdescription_~QnVpbGQgdXNlRmV0Y2godXJsKSBpbiBwbGFpbiBWdWUgc3RlcCBieSBzdGVwOiBsb2FkaW5nIGFuZCBlcnJvciByZWZzLCBhIHJlYWN0aXZlIHVybCwgcmVxdWVzdCBjYW5jZWxsYXRpb24sIGEgcmVmcmVzaCBmdW5jdGlvbiBhbmQgYSBnZW5lcmljIHR5cGUgcGFyYW1ldGVyLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hLXJldXNhYmxlLXVzZWZldGNoLWNvbXBvc2FibGUtYnktaGFuZCI%2Cs_CpuBSJLaTkFcOQrB.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~QSByZXVzYWJsZSB1c2VGZXRjaCBjb21wb3NhYmxlLCB3cml0dGVuIGJ5IGhhbmQgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~QSByZXVzYWJsZSB1c2VGZXRjaCBjb21wb3NhYmxlLCB3cml0dGVuIGJ5IGhhbmQ%2Cdescription_~QnVpbGQgdXNlRmV0Y2godXJsKSBpbiBwbGFpbiBWdWUgc3RlcCBieSBzdGVwOiBsb2FkaW5nIGFuZCBlcnJvciByZWZzLCBhIHJlYWN0aXZlIHVybCwgcmVxdWVzdCBjYW5jZWxsYXRpb24sIGEgcmVmcmVzaCBmdW5jdGlvbiBhbmQgYSBnZW5lcmljIHR5cGUgcGFyYW1ldGVyLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hLXJldXNhYmxlLXVzZWZldGNoLWNvbXBvc2FibGUtYnktaGFuZCI%2Cs_CpuBSJLaTkFcOQrB.png"}]}
```