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

Collapse
 
raju_dandigam profile image
Raju Dandigam •

@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-idempotent from duplicating side effects when a response is lost after the server commits?

Collapse
 
mwmdgmb profile image
mohammad garmabi •

Thanks — that’s exactly the boundary we aimed for: reuse TanStack Query’s AbortSignal and 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 by order:

  • retry-idempotent → order: 80
  • status handlers (onUnauthorized / onForbidden / …) → order: 95

So 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:

response !ok
  → onResponseError (sorted by order)
      1) retry-idempotent (80)
         → retry → new attempt (no status handlers)
         → continue → next interceptor
      2) status handlers (95)
         → side effects like redirect/toast
  → then FetchError for Query
Enter fullscreen mode Exit fullscreen mode

2) How do we avoid repeating side effects?

By hard constraints in the plugin — not by guessing server commit state.

retry-idempotent retries only when all of these are true:

  1. method is idempotent: GET / HEAD / OPTIONS only
  2. status is 502 / 503 / 504
  3. still within maxRetries

So 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:

  • server/client idempotency keys, or
  • an explicit custom interceptor with a clear contract

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.