If you're using TanStack Query with native fetch, your query functions may look like this:
useQuery({
queryKey: ["users"],
queryFn: async ({ signal }) => {
const response = await fetch("/api/users", {
signal,
})
if (!response.ok) {
throw new Error("Request failed")
}
return response.json()
},
})
There's nothing wrong with this.
But as the application grows, the HTTP layer usually becomes responsible for much more:
- Authentication
- HTTP error handling
- Typed errors
- Status-specific behavior
- Request cancellation
- Retries
- Interceptors
- SSR
- SSE
- Logging
This is the problem I wanted to solve with tanstack-fetch.
A small, typed
fetchclient designed around the mental model of TanStack Query.
It is not an official TanStack package.
Installation
npm install tanstack-fetch @tanstack/react-query
The package requires Node 18+ when running in Node environments because it relies on native Fetch.
1. Create a typed API client
import { createFetch } from "tanstack-fetch"
export const api = createFetch({
baseUrl: "https://api.example.com",
getToken: () =>
localStorage.getItem("access_token"),
})
Now your API client can be used directly inside TanStack Query.
import { useQuery } from "@tanstack/react-query"
type User = {
id: string
name: string
}
const usersQuery = useQuery({
queryKey: ["users"],
queryFn: ({ signal }) =>
api.get<User[]>("/users", {
signal,
}),
})
Notice that the signal comes directly from TanStack Query.
No custom cancellation mechanism is required.
2. Typed HTTP errors
One important difference between native Fetch and Axios-style clients is HTTP error handling.
fetch() doesn't reject just because the server returns 404 or 500.
For example:
const response = await fetch("/api/users")
can resolve even when:
HTTP 500
was returned.
For a TanStack Query queryFn, that's usually not what we want.
The query function should throw when the HTTP request represents an error.
tanstack-fetch converts HTTP failures into FetchError.
const query = useQuery({
queryKey: ["users"],
queryFn: ({ signal }) =>
api.get<User[]>("/users", {
signal,
}),
})
You can inspect the error:
import { isFetchError } from "tanstack-fetch"
if (isFetchError(query.error)) {
console.log(query.error.status)
console.log(query.error.code)
console.log(query.error.body)
}
This makes the transport layer and TanStack Query work together naturally.
3. Global 401 / 403 / 404 / 5xx handling
Instead of repeating authentication logic in every query:
const api = createFetch({
baseUrl: "https://api.example.com",
getToken: () =>
localStorage.getItem("access_token"),
onUnauthorized: () => {
localStorage.removeItem("access_token")
window.location.href = "/login"
},
onForbidden: () => {
console.warn("Forbidden")
},
onNotFound: ({ error }) => {
console.warn("Resource not found:", error.message)
},
onServerError: ({ status }) => {
console.error("Server error:", status)
},
})
The handler executes before the error is thrown, so TanStack Query still receives the error state.
You can also use an advanced status map:
const api = createFetch({
baseUrl: "https://api.example.com",
onStatus: {
401: () => redirect("/login"),
403: () => {
toast.error("Forbidden")
},
404: () => {
toast.error("Not found")
},
500: () => {
toast.error("Server error")
},
"5xx": ({ status }) => {
console.error("Upstream error:", status)
},
default: ({ status }) => {
console.warn("Unhandled:", status)
},
},
})
4. Plugins and interceptors
For larger applications, you may need behavior beyond authentication and status handling.
For example:
const api = createFetch({
baseUrl: "https://api.example.com",
plugins: [
"trace",
"ssr-forward",
"retry-idempotent",
"sse-resume",
],
getToken: () =>
getAccessToken(),
})
You can also create named interceptors:
const api = createFetch({
baseUrl: "https://api.example.com",
interceptors: [
{
name: "locale",
order: 25,
onRequest: (context) => {
context.request.headers.set(
"accept-language",
"en",
)
return {
action: "continue",
context,
}
},
},
],
})
And dynamically register one:
api.use("audit", {
onResponse: (context) => {
console.log(
context.response?.status,
context.request.url.pathname,
)
return {
action: "continue",
context,
}
},
})
The goal is to keep the HTTP core small while allowing advanced applications to compose additional behavior.
5. React integration
React support is optional.
Install the same package and use:
import {
FetchProvider,
useFetch,
} from "tanstack-fetch/react"
Wrap your application:
const App = () => (
<FetchProvider
baseUrl={import.meta.env.VITE_API_URL}
getToken={() =>
localStorage.getItem("access_token")
}
>
<UsersPage />
</FetchProvider>
)
Then:
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((user) => (
<li key={user.id}>
{user.name}
</li>
))}
</ul>
)
}
You don't have to use the React integration.
The core client can be used independently.
6. SSE without forcing it into the core
SSE is available through a separate entry point:
import { createFetch } from "tanstack-fetch/sse"
This is intentional.
The HTTP-only import stays small:
import { createFetch } from "tanstack-fetch"
And streaming functionality is opt-in.
The package currently exposes separate entry points for HTTP, SSE, plugins, and React integration.
7. Why not Axios?
This isn't an attempt to say:
Axios is bad.
Axios is a mature and excellent HTTP client.
The design question was different:
What would a Fetch client look like if its API was designed specifically to fit TanStack Query?
TanStack Query already handles:
- Caching
- Refetching
- Server state
- Mutations
- Pagination
- Infinite queries
- Synchronization
- Background updates
- Cancellation
The HTTP client should focus on HTTP.
That's the separation I wanted.
┌──────────────────────────────┐
│ React / Vue / etc. │
├──────────────────────────────┤
│ TanStack Query │
│ │
│ Cache / Queries / Mutations │
│ Refetch / Sync / Pagination │
├──────────────────────────────┤
│ tanstack-fetch │
│ │
│ HTTP / Auth / Errors / SSE │
│ Interceptors / Retries │
├──────────────────────────────┤
│ Native Fetch │
└──────────────────────────────┘
TanStack Query itself is transport-agnostic, which is what makes this separation possible.
8. Keep the core small
One of the main design decisions behind the package is opt-in complexity.
The basic case should remain:
const api = createFetch({
baseUrl: "...",
})
Then:
api.get(...)
api.post(...)
api.put(...)
api.patch(...)
api.delete(...)
If you need SSE:
tanstack-fetch/sse
If you need plugins:
tanstack-fetch/plugins
If you need React integration:
tanstack-fetch/react
This keeps the default API simple and lets bundlers eliminate functionality you don't use. The current package documentation reports the HTTP-only build at roughly 3.5 KB gzip, with separate optional entry points.
9. When should you use it?
I think tanstack-fetch makes the most sense if:
- You're already using TanStack Query.
- You prefer native Fetch.
- You want typed HTTP errors.
- You want centralized HTTP status handling.
- You need request cancellation.
- You want a small transport layer.
- You need optional SSE.
- You don't want your HTTP client coupled to React.
If you're already happy with Axios or another HTTP abstraction, there's no reason to migrate just because this package exists.
Try it
npm
https://www.npmjs.com/package/tanstack-fetch
GitHub
https://github.com/mohamadgarmabi/tanstack-fetch
I'd love feedback, especially from people working on large TypeScript applications.
The project is still evolving, and I'm particularly interested in what should belong in the core HTTP layer versus what should remain an optional plugin.
Tech stack: TypeScript · Fetch API · TanStack Query · React · SSE · SSR
Top comments (4)
@mwmdgmb, aligning the client with TanStack Query’s AbortSignal and error lifecycle is a sensible boundary; it avoids inventing cancellation while still turning HTTP status failures into typed query errors. The ordered plugin and interceptor model is where composition can become subtle. Do retries run before or after status handlers, and how do you prevent
retry-idempotentfrom duplicating side effects when a response is lost after the server commits?Thanks — that’s exactly the boundary we aimed for: reuse TanStack Query’s
AbortSignaland error lifecycle, and map HTTP failures into typed errors instead of inventing a parallel cancel/error model.1) Do retries run before or after status handlers?
Both run on
onResponseError(after a non-OK response), ordered byorder:retry-idempotent→order: 80onUnauthorized/onForbidden/ …) →order: 95So retry decides first.
More importantly: if retry returns
{ action: 'retry' }, the interceptor chain short-circuits on that attempt and status handlers do not run. Status handlers only run when retry chooses to continue (i.e. not retry), and then the error is thrown for Query.Per failed attempt:
2) How do we avoid repeating side effects?
By hard constraints in the plugin — not by guessing server commit state.
retry-idempotentretries only when all of these are true:GET/HEAD/OPTIONSonly502/503/504maxRetriesSo it intentionally never retries
POST/PUT/PATCH/DELETE.That directly addresses the “response lost on the way back, but the server already committed” case. For mutating methods, client-side auto-retry without a server-side idempotency key can duplicate side effects. We keep that out of scope for this plugin by not retrying mutations.
Same idea for transport failures: this plugin only hooks
onResponseError; it is not a blanket network-retry for every method.If you need at-least-once semantics for mutations, the right path is:
not
retry-idempotent.So the model is deliberately conservative: retry only where repeating the request is HTTP-safe; status handlers run after that decision, and only on the final non-retried failure.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.