React axios vs tanstack-fetch for TanStack Query
Originally published at https://mohamadgarmabi.github.io/tanstack-fetch/blog/react-axios-tanstack-fetch Stack React 18+ · tanstack-fetch/react · @tanstack/react-query Most React apps call axios inside useQuery, then
Originally published at https://mohamadgarmabi.github.io/tanstack-fetch/blog/react-axios-tanstack-fetch
Stack
React 18+ ·
tanstack-fetch/react·@tanstack/react-query
Most React apps call axios inside useQuery, then stack a .data unwrap, an error mapper and a refresh-token queue on top. Each of those exists only because axios predates the queryFn contract: return data, throw on failure, honor AbortSignal.
That gap shows up as .data unwraps, custom error mappers, and refresh-token races. tanstack-fetch closes it with the same client every other stack uses — via FetchProvider / useFetch when you want context.
TL;DR
| If you need… | Prefer |
|---|---|
Drop into useQuery / useMutation
|
tanstack-fetch |
Typed FetchError with status + body |
tanstack-fetch |
SSE with Authorization + resume |
tanstack-fetch/sse + useSse
|
| Huge axios interceptor stack, no Query | axios can stay |
| Share one client with Vue/Solid/Angular | tanstack-fetch |
npm install tanstack-fetch @tanstack/react-query
Mental model
axios returns AxiosResponse. TanStack Query wants the body and a thrown error. Bridging is possible — and noisy.
Flow: Component (hooks · JSX) → React Query (useQuery · cache) → tanstack-fetch (useFetch() · createFetch — data · throw · signal) → API (REST · SSE · tRPC)
Contract that matters for Query:
- Success → return the parsed body
- HTTP failure → throw (so
isErrorworks) - Cancel → pass Query’s
signalthrough
Side-by-side
axios + Query (glue)
import axios from 'axios'
import { useQuery } from '@tanstack/react-query'
const UsersPage = () => {
const { data, error, isPending } = useQuery({
queryKey: ['users'],
queryFn: async ({ signal }) => {
const response = await axios.get<User[]>('/users', { signal })
return response.data // easy to forget
},
})
// map AxiosError → UI yourself
}
tanstack-fetch + Query (native)
import { FetchProvider, useFetch } from 'tanstack-fetch/react'
import { useQuery } from '@tanstack/react-query'
import { isFetchError } from 'tanstack-fetch'
const App = () => (
<FetchProvider
baseUrl={import.meta.env.VITE_API_URL}
getToken={() => localStorage.getItem('access_token')}
>
<UsersPage />
</FetchProvider>
)
const UsersPage = () => {
const api = useFetch()
const { data, error, isPending } = useQuery({
queryKey: ['users'],
queryFn: ({ signal }) => api.get<User[]>('/users', { signal }),
})
if (isPending) return <p>Loading…</p>
if (isFetchError(error)) return <p>{error.status}: {error.message}</p>
return <ul>{data.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
}
Bootstrap / wiring
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { FetchProvider } from 'tanstack-fetch/react'
import { createFetch } from 'tanstack-fetch'
import { createAuthInterceptor } from 'tanstack-fetch/plugins'
const api = createFetch({
baseUrl: import.meta.env.VITE_API_URL,
interceptors: [createAuthInterceptor({ getToken: () => localStorage.getItem('access_token') })],
})
const queryClient = new QueryClient()
export const App = () => (
<QueryClientProvider client={queryClient}>
<FetchProvider client={api}>
<Routes />
</FetchProvider>
</QueryClientProvider>
)
Or skip the provider and import a module-level api into every queryFn — FetchProvider is optional.
Comparison graph: where glue accumulates
Rough relative boilerplate (lower is better) — a map of where teams spend time, not a micro-benchmark.
| Task | tanstack-fetch | Typical axios path |
|---|---|---|
| Query-ready GET | tanstack-fetch (~18%) | axios + .data (~70%) |
| Typed HTTP errors | FetchError (~22%) | AxiosError map (~65%) |
| 401 refresh + retry | refresh-token plugin (~28%) | interceptor queue (~88%) |
| SSE + Authorization | useSse (~30%) | EventSource / custom (~95%) |
| Shared with Vue/Solid | one createFetch (~20%) | rewrite per stack (~88%) |
Feature matrix
| Capability | tanstack-fetch | axios |
|---|---|---|
Returns body for Query queryFn
|
Yes | Adapt (.data) |
| Throws typed error with status/body | FetchError |
Partial |
Native AbortSignal
|
First-class | Yes (adapt) |
Status handlers 401–429
|
createStatusInterceptor |
Custom |
| Refresh token single-flight | Plugin | Custom |
Path params typing :id
|
Yes | Manual |
| Upload + progress |
api.upload() (tanstack-fetch/sse) |
Yes |
SSE + Authorization
|
tanstack-fetch/sse |
No |
| tRPC transport | tanstack-fetch/trpc |
DIY |
| Bundle (HTTP core) | 3.68KB gzip | Larger |
Auth, status handlers, refresh
import { createFetch } from 'tanstack-fetch'
import {
createAuthInterceptor,
createRefreshTokenInterceptor,
createStatusInterceptor,
} from 'tanstack-fetch/plugins'
let expiresAt = 0
const api = createFetch({
baseUrl: '/api',
interceptors: [
createAuthInterceptor({ getToken: () => localStorage.getItem('access_token') }),
createStatusInterceptor({ 403: () => toast.error('Forbidden') }),
],
})
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
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useFetch } from 'tanstack-fetch/react'
const CreateUser = () => {
const api = useFetch()
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: (input: CreateUserInput) => api.post<User>('/users', { body: input }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
})
return <button onClick={() => mutation.mutate({ name: 'Ada' })}>Create</button>
}
Live data: SSE
Browser EventSource cannot set Authorization. tanstack-fetch/sse streams over fetch.
import { createFetch } from 'tanstack-fetch/sse'
import { useSse } from 'tanstack-fetch/react'
const api = createFetch({
plugins: ['sse-resume'],
getToken: () => localStorage.getItem('access_token'),
})
const OrdersLive = () => {
const { data, status, error, close } = useSse<OrderEvent>('/orders/stream', { client: api })
if (status === 'error') return <p>{error?.message}</p>
return (
<div>
<p>{status}</p>
<pre>{JSON.stringify(data, null, 2)}</pre>
<button type="button" onClick={close}>Stop</button>
</div>
)
}
FetchProvider is not required for SSE — pass { client }.
Status: connecting | connected | disconnected | error.
connecting → connected → onMessage → data → close / unmount
tRPC over the same client
import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query'
import { useQuery, QueryClient } from '@tanstack/react-query'
import { createTRPCClient, httpBatchLink } from '@trpc/client'
import { createTRPCFetch } from 'tanstack-fetch/trpc'
import { api } from './api'
import type { AppRouter } from './server'
const queryClient = new QueryClient()
const trpcClient = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: '/api/trpc', fetch: createTRPCFetch(api) })],
})
const trpc = createTRPCOptionsProxy<AppRouter>({ client: trpcClient, queryClient })
useQuery(trpc.post.list.queryOptions())
Migrate
npx tanstack-fetch migrate --from axios --framework react --write
npx tanstack-fetch doctor --dir ./src
- One
createFetch(+ optionalFetchProvider) - Port
useQueryreads first - Port mutations
- Swap custom 401 refresh for the plugin
- Delete axios when call sites are gone
Suggested folder layout
src/
lib/api.ts
queries/users.ts
components/UsersPage.tsx
components/OrdersLive.tsx
App.tsx # QueryClientProvider + FetchProvider
Decision tree
-
Is TanStack Query your source of truth for server state?
- Yes → start with
tanstack-fetch - No → axios may stay
- Yes → start with
-
Do you need SSE with Authorization or resume?
- Yes →
tanstack-fetch/sse+useSse - No → either client works for simple REST
- Yes →
-
Do other frameworks share this backend client?
- Yes → one
createFetchacross packages - No → still fine; adapters are optional
- Yes → one
Checklist
- [ ] Optional
FetchProvideror sharedapimodule - [ ] Every
queryFn/mutationFnforwards{ signal } - [ ] Errors via
isFetchErrorwhen you needstatus - [ ] Refresh via plugin
- [ ] SSE from
tanstack-fetch/ssewhen auth matters - [ ]
npx tanstack-fetch doctor --dir ./srcclean
Try it
npm install tanstack-fetch @tanstack/react-query
npx tanstack-fetch doctor --dir ./src
Related
Series
Same deep-dive format for every stack:
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.