DEV Community

mohammad garmabi
mohammad garmabi

Posted on

tanstack-fetch: A Typed Fetch Client Built for TanStack Query

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()
  },
})
Enter fullscreen mode Exit fullscreen mode

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 fetch client designed around the mental model of TanStack Query.

It is not an official TanStack package.

Installation

npm install tanstack-fetch @tanstack/react-query
Enter fullscreen mode Exit fullscreen mode

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"),
})
Enter fullscreen mode Exit fullscreen mode

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,
    }),
})
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

can resolve even when:

HTTP 500
Enter fullscreen mode Exit fullscreen mode

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,
    }),
})
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
  },
})
Enter fullscreen mode Exit fullscreen mode

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)
    },
  },
})
Enter fullscreen mode Exit fullscreen mode

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(),
})
Enter fullscreen mode Exit fullscreen mode

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,
        }
      },
    },
  ],
})
Enter fullscreen mode Exit fullscreen mode

And dynamically register one:

api.use("audit", {
  onResponse: (context) => {
    console.log(
      context.response?.status,
      context.request.url.pathname,
    )

    return {
      action: "continue",
      context,
    }
  },
})
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Wrap your application:

const App = () => (
  <FetchProvider
    baseUrl={import.meta.env.VITE_API_URL}
    getToken={() =>
      localStorage.getItem("access_token")
    }
  >
    <UsersPage />
  </FetchProvider>
)
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

This is intentional.

The HTTP-only import stays small:

import { createFetch } from "tanstack-fetch"
Enter fullscreen mode Exit fullscreen mode

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         │
└──────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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: "...",
})
Enter fullscreen mode Exit fullscreen mode

Then:

api.get(...)
api.post(...)
api.put(...)
api.patch(...)
api.delete(...)
Enter fullscreen mode Exit fullscreen mode

If you need SSE:

tanstack-fetch/sse
Enter fullscreen mode Exit fullscreen mode

If you need plugins:

tanstack-fetch/plugins
Enter fullscreen mode Exit fullscreen mode

If you need React integration:

tanstack-fetch/react
Enter fullscreen mode Exit fullscreen mode

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)