VueUse's useFetch is the most-reached-for fetch composable in the Vue world β one line gives you data, error, isFetching refs and you move on. Its defaults are more opinionated than most people realize, in both directions: it will silently not refetch a reactive URL you thought was wired up, and it will silently win a request race other libraries lose. This guide puts numbers on both, plus the quietest infinite loop we've measured in this series.
Everything here was reproduced and measured in a real Chrome on @vueuse/core 13.9.0 + Vue 3.5.43 (Vite, client-side) with an instrumented window.fetch, render counters, and an abort counter before publishing. Two full runs produced identical numbers (the loop scenario varies with machine speed, as you'd expect).
A shared, self-resetting demo project is live right now:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"
That URL is the API for every snippet below. CORS is open (Access-Control-Allow-Origin: *), so it works from localhost:5173, StackBlitz, CI β anywhere. When you want your own schema it's one curl or one click β see Β§15.
import { useFetch } from '@vueuse/core'
const { isFetching, error, data } = useFetch(`${API}/products`).get().json()
<p v-if="isFetching">loadingβ¦</p>
<p v-else-if="error">something broke</p>
<ul v-else><li v-for="p in data" :key="p.id">{{ p.name }}</li></ul>
Measured: 1 fetch, 20 rows (the demo API's default page size; X-Total-Count: 30 tells you the rest), 4 renders (idle β fetching β data β settled), clean console. No storm risk here β useFetch runs in setup(), which Vue executes once per component instance, so the whole class of fetch-in-render accidents that plague React can't happen in the normal case.
You know URLs can be reactive, so you wire a pager the obvious way:
const page = ref(1)
const url = computed(() =>
`${API}/products?_page=${page.value}&_limit=5`)
const { data } = useFetch(url).get().json() // β looks reactive. Isn't.
Click "next". Measured: header says "page 2", the rows are still page 1's (first id 1), the network shows 1 fetch before the click and 1 fetch after β i.e. nothing happened β and the console shows zero warnings. useFetch happily accepts a ref or computed URL, unwraps it once at call time, and by default never looks at it again. The bug ships because the first page renders perfectly.
The fix is one option β this is exactly what refetch is for:
const { data } = useFetch(url, { refetch: true }).get().json()
Same click, measured: 2 fetches, rows swap to page 2 (first id 6). If your reactive URL "doesn't work", it's not broken β it's opt-in.
The scenario that breaks raw Svelte 5 $effect, naive zustand actions, and naive Pinia actions: request page 2 (answers in 1200 ms), then page 3 (answers in 100 ms) 150 ms later. Last-to-resolve wins, so those three paint page-2 rows over a page-3 header. To reproduce deterministically, the mock decides who's slow β mock_delay is per-request:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?_page=2&_limit=5&mock_delay=1200"
curl "https://mockbird.mockbird.workers.dev/m/demo/products?_page=3&_limit=5&mock_delay=100"
Measured with refetch: true and that exact click sequence: 3 fetches, exactly 1 abort, header "page 3", and the rows are page 3's (first id 11). When the URL changes mid-flight, useFetch aborts the previous request itself β you write zero AbortController code, and the stale response doesn't even finish downloading. Compare: in zustand and MobX you build the controller into the store by hand, and Solid gives you no signal at all. This is the strongest default in the series β as long as you remembered Β§3.
refetch: true has its own failure mode. A cursor-style pager where the URL depends on the data it loaded:
const last = computed(() => data.value?.at(-1)?.id ?? 0)
const url = computed(() =>
`${API}/products?id_gt=${last.value}&limit=5`)
const { data } = useFetch(url, { refetch: true }).get().json() // β feedback loop
Response arrives β last changes β URL changes β refetch β response arrivesβ¦ and when the cursor runs off the end, the empty array resets last to 0 and the whole cycle restarts. Measured: 40β43 fetches in the first 2 seconds, 104β110 by 5 seconds, renders tracking fetches 1:1, 0 aborts (each request completes before the next fires), and the console: completely silent. No "maximum recursion", no warning, nothing β this loop runs against a metered API until someone reads the bill. The rule: with refetch: true, nothing your URL depends on may depend on the response. Load-more belongs in an explicit execute() call (Β§9), not in URL reactivity.
const { data, error, statusCode } =
useFetch(`${API}/products?mock_status=500`).get().json()
Measured: statusCode is 500, data is null, and error isβ¦ the string "Internal Server Error" β the HTTP statusText, not an Error object, not your API's error payload. Whatever useful JSON your server put in the body ({"error": "β¦"}, validation details, a request id) is discarded by default. Two practical consequences we measured:
{{ data.length }} products β throws Cannot read properties of null (reading 'length') and the component unmounts to a childless root. Guard with v-if="data" or set initialData: [].updateDataOnError: true, measured data becomes the parsed error body verbatim ({"error":"simulated 500 error (mock_status)"}). Now your success path can receive an error object β the body-as-data bug as a documented option. If you use it, branch on statusCode before touching data.Drill every branch against real statuses before shipping:
curl -i "https://mockbird.mockbird.workers.dev/m/demo/products?mock_status=500" # any code: 401, 403, 429β¦
const { error, aborted } =
useFetch(`${API}/products?mock_delay=3000`, { timeout: 800 }).get().json()
Measured against an endpoint slowed to 3 s: at 800 ms the request aborts (1 abort, aborted: true, isFinished: true) and error reads "signal is aborted without reason". Nothing in any ref says "timeout" β if you show error raw, your users see DOM-abort jargon for what is really "the server was slow". Branch on aborted and write your own message. (mock_delay is how we made the server reliably slow; your staging box won't do that on demand.)
Mid-flight, measured: canAbort is true; calling abort() flips aborted: true, canAbort: false. The part tutorials skip: call execute() again and the instance recovers cleanly β measured aborted resets to false and the rows land (2 fetches, 1 abort total). An aborted useFetch is paused, not dead β wire "Cancel" and "Retry" to the same instance.
const { data, execute } =
useFetch(url, { immediate: false }).get().json()
Measured: 0 fetches until execute() is called, then exactly 1. This is the right home for load-more, search-on-submit, and anything Β§5 tempted you to do with URL reactivity. execute() returns a promise, so await execute() sequences cleanly in handlers.
What does the user see during a refetch? We slowed page 2 to 800 ms and sampled the DOM at 400 ms: the old page-1 rows are still on screen (data is not reset to null), with isFetching: true alongside. That's keep-previous-data by default β no fallback flash, unlike jotai's re-suspending pager, which needs unwrap() to behave this way. The measured caveat is the same one every keep-previous UI has: the header reads the sync page ref and already says "page 2" over page-1 rows for the whole flight. Use isFetching to dim the stale list or pin a spinner to the header.
Per-app defaults live in a factory, not in every call site:
const useApi = createFetch({
baseUrl: API,
options: {
beforeFetch({ options }) {
options.headers = { ...options.headers, Authorization: `Bearer ${token}` }
return { options }
},
afterFetch(ctx) { ctx.data = ctx.data.items ?? ctx.data; return ctx },
},
})
We verified this end-to-end rather than trusting the docs: pointed it at an endpoint that echoes request headers back, and the response confirmed the server received Authorization: Bearer test-token; afterFetch reshaped the payload before data updated. Your mock can echo headers too β the demo ships an httpbin-compatible surface:
curl -H "Authorization: Bearer test-token" "https://mockbird.mockbird.workers.dev/m/httpbin/headers"
Two sibling components each calling useFetch(sameUrl). Measured: 2 fetches. There is no cache, no deduplication, no shared state β every call site owns its own request. That's a design choice, not a flaw, but it collides with two common assumptions: people arriving from Nuxt (whose same-named useFetch dedupes by key β though its "sharing" is really cancel-and-replace: measured separately) and people expecting SWR/TanStack-Query behavior. If you want request sharing, lift the useFetch into a composable singleton, or use an actual cache layer (Pinia Colada, TanStack Query β measured).
useFetch has no retry option β the loop is yours, and "retry on 5xx" paths usually ship untested because you can't make prod fail twice on demand. mock_seq serves a deterministic status script β 503, 503, then the real data:
const { statusCode, execute } = useFetch(
`${API}/products?mock_seq=503,503,200&mock_seq_key=run1`,
{ immediate: false }).get().json()
for (let i = 0; i < 5; i++) {
await execute()
if (statusCode.value === 200) break
}
Measured: exactly 3 fetches, then rows render. Each response carries x-mockbird-seq: 1/3 β¦ 3/3 so every attempt is assertable; the sequence sticks on its last entry per key, so use a fresh mock_seq_key (or mock_seq_reset=1) per test run.
Seeded dev data means your "No products yet" branch ships untested. Pin a request to an empty snapshot β live data untouched:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?mock_snapshot=empty" # β []
Measured in the harness: isFinished true, error null, zero rows, and the empty-state line renders cleanly. One wrinkle specific to useFetch: before the first response, data is null β so "loading", "error", "empty", and "rows" are four distinct states and data?.length === 0 is the only honest empty check.
curl -X POST "https://mockbird.mockbird.workers.dev/api/projects" \
-H "content-type: application/json" \
-d '{"preset": "ecommerce"}'
That returns a live base URL with seeded products, orders, customers, reviews β full CRUD, filters (?price_gte=100, ?id_gt=25 as used above), pagination with X-Total-Count, relations (?_expand=customer), GraphQL, OpenAPI + TypeScript/Zod exports, and every simulation flag used above (mock_status, mock_delay, mock_seq, envelope reshaping, chaos). Or skip the terminal: one-click create / import your own spec or data. Free, no signup required, eject anytime.
Written by the Mockbird maker β bias disclosed. useFetch measures well where it matters most: the refetch race is unloseable with zero plumbing (Β§4), keep-previous-data is the default (Β§10), and setup()-scoped execution rules out the render-loop storms other frameworks suffer. Its sharp edges are all quiet defaults: reactive URLs don't refetch until you say so (Β§3), the one refetch loop you can build is completely silent (Β§5), and error handling gives you a statusText string while discarding the body (Β§6). If you need caching, dedup, or mutations-with-invalidation, useFetch is deliberately not that β reach for Pinia Colada or TanStack Query. Measurements taken Sep 24, 2026 on @vueuse/core 13.9.0, Vue 3.5.43, Vite 5, client-side rendering; two runs, identical numbers (loop scenario Β±6%). Related: mocking for Vue, watchEffect fetching, measured, Pinia, measured, Nuxt useAsyncData & useFetch, measured.