Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

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:

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

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
  1. One createFetch (+ optional FetchProvider)
  2. Port useQuery reads first
  3. Port mutations
  4. Swap custom 401 refresh for the plugin
  5. 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

  1. Is TanStack Query your source of truth for server state?
    • Yes → start with tanstack-fetch
    • No → 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

  • [ ] Optional FetchProvider or shared api module
  • [ ] Every queryFn / mutationFn forwards { signal }
  • [ ] Errors via isFetchError when you need status
  • [ ] Refresh via plugin
  • [ ] SSE from tanstack-fetch/sse when auth matters
  • [ ] npx tanstack-fetch doctor --dir ./src clean

Try it

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

Related

Series

Same deep-dive format for every stack:

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.