Skip to content

Vue ofetch / axios vs tanstack-fetch ​

October 12, 2026 · Vue · TanStack Query · HTTP

Stack

Vue 3.3+ · tanstack-fetch/vue · @tanstack/vue-query

ofetch and axios are fine transporters. They are not shaped for Vue Query’s queryFn contract: return data, throw on failure, honor AbortSignal.

tanstack-fetch gives Vue the same client as React — via createFetchPlugin + useFetch composables.

This post is the deep dive: architecture, side-by-side code, graphs, migrate path, SSE, tRPC, and when to keep the default stack client.

TL;DR ​

If you need…Prefer
Drop into useQuery / useMutationtanstack-fetch
Typed FetchErrortanstack-fetch
SSE + AuthorizationuseSse from tanstack-fetch/vue
Simple script without Queryofetch is fine
Share client with React/Nuxttanstack-fetch
bash
npm install tanstack-fetch vue @tanstack/vue-query

Mental model ​

ofetch returns the body on success but error shapes differ from Query’s expectations. axios still wants .data. One client should do both REST and SSE.

Contract that matters for Query:

  1. Success → return the parsed body
  2. HTTP failure → throw (so isError works)
  3. Cancel → pass Query’s signal through

Side-by-side ​

axios / ofetch + Query (glue) ​

ts
import axios from 'axios'
import { useQuery } from '@tanstack/vue-query'

const { data, error, isPending } = useQuery({
  queryKey: ['users'],
  queryFn: async ({ signal }) => {
    const res = await axios.get<User[]>('/users', { signal })
    return res.data
  },
})

tanstack-fetch + Vue Query ​

ts
import { createApp } from 'vue'
import { createFetch } from 'tanstack-fetch'
import { createFetchPlugin, useFetch } from 'tanstack-fetch/vue'
import { VueQueryPlugin, useQuery } from '@tanstack/vue-query'
import { isFetchError } from 'tanstack-fetch'
import App from './App.vue'

const api = createFetch({
  baseUrl: import.meta.env.VITE_API_URL,
  getToken: () => localStorage.getItem('access_token'),
})

createApp(App)
  .use(createFetchPlugin({ client: api }))
  .use(VueQueryPlugin)
  .mount('#app')
vue
<script setup lang="ts">
import { useFetch } from 'tanstack-fetch/vue'
import { useQuery } from '@tanstack/vue-query'
import { isFetchError } from 'tanstack-fetch'

const api = useFetch()
const { data, error, isPending } = useQuery({
  queryKey: ['users'],
  queryFn: ({ signal }) => api.get<User[]>('/users', { signal }),
})
</script>

<template>
  <p v-if="isPending">Loading…</p>
  <p v-else-if="isFetchError(error)">{{ error.status }}</p>
  <ul v-else>
    <li v-for="u in data" :key="u.id">{{ u.name }}</li>
  </ul>
</template>

Bootstrap / wiring ​

Pass createFetch options straight into createFetchPlugin({ baseUrl, getToken, … }) if you do not want a separate api module.

In Nuxt, rename when useFetch clashes: import { useFetch as useFetchClient } from 'tanstack-fetch/vue'.

Comparison graph: where glue accumulates ​

Rough relative boilerplate (lower is better) — a map of where teams spend time, not a micro-benchmark.

Feature matrix ​

Capabilitytanstack-fetchofetch / axios
Returns body for QueryYesAdapt
Typed error + statusFetchErrorPartial
AbortSignalFirst-classYes / vary
Status handlersBuilt-inCustom
Refresh single-flightPluginCustom
Path params :idYesManual
SSE + AuthorizationYesNo
Vue composablesuseFetch / useSseDIY
Bundle~4.8KB gzipSmall–medium

Auth, status handlers, refresh ​

ts
import { createFetch } from 'tanstack-fetch'
import { createRefreshTokenInterceptor } from 'tanstack-fetch/plugins'

let expiresAt = 0

const api = createFetch({
  baseUrl: '/api',
  getToken: () => localStorage.getItem('access_token'),
})

api.use(
  'refresh-token',
  createRefreshTokenInterceptor({
    refresh: async () => {
      const body = await api.post<{ accessToken: string; expiresIn: number }>('/auth/refresh', {
        interceptors: { eject: ['refresh-token', 'auth'] },
      })
      localStorage.setItem('access_token', body.accessToken)
      expiresAt = Date.now() + body.expiresIn * 1000
    },
    before: { getExpiresAt: () => expiresAt, skewMs: 60_000 },
    after: { enabled: true },
  }),
)

The plugin keeps one in-flight refresh and retries the failed call — the usual interceptor queue, without the queue code.

Mutations ​

ts
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { useFetch } from 'tanstack-fetch/vue'

const api = useFetch()
const queryClient = useQueryClient()
const createUser = useMutation({
  mutationFn: (input: CreateUserInput) => api.post<User>('/users', { body: input }),
  onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
})

Live data: SSE ​

Browser EventSource cannot set Authorization. tanstack-fetch/sse streams over fetch.

vue
<script setup lang="ts">
import { createFetch } from 'tanstack-fetch/sse'
import { useSse } from 'tanstack-fetch/vue'

const api = createFetch({ plugins: ['sse-resume'] })
const { data, status, error, close } = useSse<OrderEvent>('/orders/stream', { client: api })
</script>

<template>
  <p v-if="status === 'error'">{{ error?.message }}</p>
  <div v-else>
    <p>{{ status }}</p>
    <pre>{{ data }}</pre>
    <button type="button" @click="close">Stop</button>
  </div>
</template>

Refs unwrap in the template. Plugin is optional when you pass { client }.

Status: connecting | connected | disconnected | error.

tRPC over the same client ​

ts
import { createTRPCClient, httpBatchLink } from '@trpc/client'
import { createTRPCFetch } from 'tanstack-fetch/trpc'
import { useQuery } from '@tanstack/vue-query'
import { api } from './api'
import type { AppRouter } from './server'

export const trpc = createTRPCClient<AppRouter>({
  links: [httpBatchLink({ url: '/api/trpc', fetch: createTRPCFetch(api) })],
})

const posts = useQuery({
  queryKey: ['posts'],
  queryFn: ({ signal }) => trpc.post.list.query(undefined, { signal }),
})

Migrate ​

bash
npx tanstack-fetch migrate --from ofetch --framework vue --write
# or
npx tanstack-fetch migrate --from axios --framework vue --write
npx tanstack-fetch doctor --dir ./src

Suggested folder layout ​

text
src/
  main.ts              # createFetchPlugin + VueQueryPlugin
  lib/api.ts
  queries/users.ts
  components/UsersList.vue
  components/OrdersLive.vue

Decision tree ​

  1. Is TanStack Query your source of truth for server state?
    • Yes → start with tanstack-fetch
    • No → ofetch/axios may stay
  2. Do you need SSE with Authorization or resume?
    • Yes → tanstack-fetch/sse + useSse
    • No → either client works for simple REST
  3. Do other frameworks share this backend client?
    • Yes → one createFetch across packages
    • No → still fine; adapters are optional

Checklist ​

  • [ ] createFetchPlugin registered
  • [ ] Forward { signal } in every queryFn
  • [ ] isFetchError for status UI
  • [ ] Refresh plugin instead of ad-hoc interceptors
  • [ ] SSE via tanstack-fetch/sse
  • [ ] Doctor clean

Try it ​

bash
npm install tanstack-fetch vue @tanstack/vue-query
npx tanstack-fetch doctor --dir ./src

Series ​

Same deep-dive format for every stack:

MIT Licensed · Created by Mohammad Garmabi · Not an official TanStack package