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 (0)