Skip to content

Angular HttpClient vs tanstack-fetch ​

October 12, 2026 · Angular · TanStack Query · HTTP

Stack

Angular 17+ · tanstack-fetch/angular · @tanstack/angular-query-experimental

Angular’s HttpClient is excellent at transporting bytes. It is not shaped for TanStack Query’s queryFn contract: return data, throw on failure, honor AbortSignal.

That gap shows up as unwrap-.data helpers, RxJS subscriptions that fight Query’s cache, and interceptors that reinvent refresh-token single-flight. tanstack-fetch closes it with the same client every other stack already uses — wired through Angular DI and signals.

This post is the deep dive: architecture, side-by-side code, graphs, migrate path, SSE, tRPC, and when you should keep HttpClient.

TL;DR ​

If you need…Prefer
Drop into injectQuery / injectMutationtanstack-fetch
Typed FetchError with status + bodytanstack-fetch
SSE with Authorization + resumetanstack-fetch/sse + useSse
Classic Angular apps with heavy RxJS pipesHttpClient is fine
One HTTP client shared with React/Vue/Solidtanstack-fetch
bash
npm install tanstack-fetch @tanstack/angular-query-experimental

Mental model ​

HttpClient speaks Observables. TanStack Query speaks Promises + AbortSignal. Bridging them is possible — and noisy.

Contract that matters for Query:

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

api.get<User[]>('/users', { signal }) does all three. http.get(...).pipe(map(r => r.body)) asks you to remember them.

Side-by-side: list users ​

Angular HttpClient + Query (glue) ​

ts
import { HttpClient } from '@angular/common/http'
import { inject } from '@angular/core'
import { injectQuery } from '@tanstack/angular-query-experimental'
import { firstValueFrom } from 'rxjs'

export class UsersHttpComponent {
  private readonly http = inject(HttpClient)

  readonly users = injectQuery(() => ({
    queryKey: ['users'],
    queryFn: async ({ signal }) => {
      // firstValueFrom + manual abort wiring
      const request = this.http.get<User[]>('/api/users')
      const sub = new AbortController()
      signal.addEventListener('abort', () => sub.abort())
      return firstValueFrom(request) // often forgets AbortSignal entirely
    },
  }))
}

Common cracks: forgetting signal, returning the whole HttpResponse, catching errors that Query should own.

tanstack-fetch + Query (native) ​

ts
import { Component } from '@angular/core'
import { injectFetch } from 'tanstack-fetch/angular'
import { injectQuery } from '@tanstack/angular-query-experimental'
import { isFetchError } from 'tanstack-fetch'

@Component({
  selector: 'app-users',
  template: `
    @if (users.isPending()) {
      <p>Loading…</p>
    } @else if (users.isError()) {
      <p>{{ errorLabel() }}</p>
    } @else {
      <ul>
        @for (user of users.data(); track user.id) {
          <li>{{ user.name }}</li>
        }
      </ul>
    }
  `,
})
export class UsersComponent {
  private readonly api = injectFetch()

  readonly users = injectQuery(() => ({
    queryKey: ['users'],
    queryFn: ({ signal }: { signal: AbortSignal }) =>
      this.api.get<User[]>('/users', { signal }),
  }))

  errorLabel = () => {
    const error = this.users.error()
    return isFetchError(error) ? `${error.status}: ${error.message}` : 'Error'
  }
}

No .data unwrap. Failures throw FetchError. Cancel is free if you forward signal.

Bootstrap once with DI ​

ts
import { bootstrapApplication } from '@angular/platform-browser'
import { provideHttpClient } from '@angular/common/http' // optional legacy
import { provideTanStackQuery, QueryClient } from '@tanstack/angular-query-experimental'
import { createFetch } from 'tanstack-fetch'
import { provideFetchClient } from 'tanstack-fetch/angular'
import { AppComponent } from './app.component'

const api = createFetch({
  baseUrl: 'https://api.example.com',
  getToken: () => localStorage.getItem('access_token'),
})

bootstrapApplication(AppComponent, {
  providers: [
    provideFetchClient({ client: api }),
    provideTanStackQuery(new QueryClient()),
    // keep HttpClient only while migrating modules
    // provideHttpClient(),
  ],
})

injectFetch() reads the FETCH_CLIENT token. If you forget provideFetchClient, you get a clear error instead of a silent undefined.

You can also pass { client } into useSse without DI — useful for SSE-only islands.

Comparison graph: where glue accumulates ​

Rough relative boilerplate for the same features (lower is better). Not a benchmark — a map of where teams spend time.

Feature matrix ​

Capabilitytanstack-fetchAngular HttpClient
Returns body for Query queryFnYesAdapt (firstValueFrom + map)
Throws typed error with status/bodyFetchError + isFetchErrorHttpErrorResponse
Native AbortSignalFirst-classManual / limited
Status handlers 401–429Built-inCustom interceptor
Refresh token single-flightPluginCustom
Path params typing :idYesManual
Upload + progressapi.upload()Yes
SSE + Authorizationtanstack-fetch/sseNot built-in
Angular signals SSEuseSseDIY
tRPC transporttanstack-fetch/trpcDIY
RxJS operators / HttpContextVia bridgeNative
Bundle (HTTP core)~4.8KB gzipFramework-included

Auth, status handlers, refresh ​

ts
import { createFetch } from 'tanstack-fetch'
import { createRefreshTokenInterceptor } from 'tanstack-fetch/plugins'

let expiresAt = 0

const api = createFetch({
  baseUrl: '/api',
  getToken: () => localStorage.getItem('access_token'),
  onUnauthorized: async () => {
    // optional: redirect to login once refresh fails
  },
})

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 },
  }),
)

HttpClient teams usually encode the same flow as a multi-file interceptor with a shared BehaviorSubject queue. The plugin keeps one in-flight refresh and retries the failed call.

Mutations without ceremony ​

ts
import { injectMutation, injectQueryClient } from '@tanstack/angular-query-experimental'
import { injectFetch } from 'tanstack-fetch/angular'

export class CreateUserComponent {
  private readonly api = injectFetch()
  private readonly queryClient = injectQueryClient()

  readonly createUser = injectMutation(() => ({
    mutationFn: (input: CreateUserInput) => this.api.post<User>('/users', { body: input }),
    onSuccess: () => {
      void this.queryClient.invalidateQueries({ queryKey: ['users'] })
    },
  }))
}

Live data: SSE as signals ​

Browser EventSource cannot set Authorization. tanstack-fetch/sse streams over fetch, so cookies or Bearer tokens work.

ts
import { Component } from '@angular/core'
import { createFetch } from 'tanstack-fetch/sse'
import { useSse } from 'tanstack-fetch/angular'

const live = createFetch({
  baseUrl: '/api',
  plugins: ['sse-resume'],
  getToken: () => localStorage.getItem('access_token'),
})

@Component({
  selector: 'app-orders-live',
  template: `
    <p class="status">{{ status() }}</p>
    @if (error(); as err) {
      <p class="error">{{ err.message }}</p>
    }
    <pre>{{ data() | json }}</pre>
    <button type="button" (click)="close()">Stop</button>
  `,
})
export class OrdersLiveComponent {
  private readonly stream = useSse<OrderEvent>('/orders/stream', { client: live })
  readonly data = this.stream.data
  readonly status = this.stream.status
  readonly error = this.stream.error
  readonly close = this.stream.close
}

Status model matches React/Vue/Solid: connecting | connected | disconnected | error.

useSse hooks DestroyRef so the stream closes when the component is destroyed.

tRPC over the same client ​

ts
import { createTRPCClient, httpBatchLink } from '@trpc/client'
import { createTRPCFetch } from 'tanstack-fetch/trpc'
import { injectQuery } from '@tanstack/angular-query-experimental'
import { injectFetch } from 'tanstack-fetch/angular'
import type { AppRouter } from './server'

export class PostsComponent {
  private readonly api = injectFetch()

  private readonly trpc = createTRPCClient<AppRouter>({
    links: [
      httpBatchLink({
        url: '/api/trpc',
        fetch: createTRPCFetch(this.api),
      }),
    ],
  })

  readonly posts = injectQuery(() => ({
    queryKey: ['posts'],
    queryFn: ({ signal }: { signal: AbortSignal }) =>
      this.trpc.post.list.query(undefined, { signal }),
  }))
}

Auth headers, refresh-token, and status handlers apply to tRPC the same way they apply to REST.

Migrate from axios / HttpClient habits ​

bash
# scaffold Angular + Query options from axios-shaped code
npx tanstack-fetch migrate --from axios --framework angular --write

# health check leftovers
npx tanstack-fetch doctor --dir ./src

--framework angular writes something like src/app/api.ts plus query-option helpers aimed at @tanstack/angular-query-experimental.

Suggested migration order:

  1. Create one createFetch client + provideFetchClient
  2. Move read paths into injectQuery first (highest Query win)
  3. Port mutations
  4. Replace custom 401 refresh with the plugin
  5. Keep HttpClient only where RxJS pipelines are still the product

Suggested folder layout ​

text
src/app/
  api.ts                 # createFetch singleton
  app.config.ts          # provideFetchClient + provideTanStackQuery
  queries/
    users.query.ts       # queryOptions factories
  features/
    users/
      users.component.ts # injectFetch + injectQuery
    live/
      orders-live.component.ts  # useSse
ts
// src/app/api.ts
import { createFetch } from 'tanstack-fetch'

export const api = createFetch({
  baseUrl: '/api',
  getToken: () => localStorage.getItem('access_token'),
})
ts
// src/app/queries/users.query.ts
import { queryOptions } from '@tanstack/angular-query-experimental'
import { api } from '../api'

export const usersQueryOptions = queryOptions({
  queryKey: ['users'] as const,
  queryFn: ({ signal }) => api.get<User[]>('/users', { signal }),
})

Decision tree ​

  1. Is TanStack Query (or Angular Query) your source of truth for server state?
    • Yes → start with tanstack-fetch
    • No, pure RxJS streams everywhere → HttpClient may stay
  2. Do you need SSE with Authorization or resume?
    • Yes → tanstack-fetch/sse + useSse
    • No → either client works for REST
  3. Do React / Vue / Solid share the same backend client?
    • Yes → one createFetch across packages
    • No → still fine; Angular DI adapters are optional
  4. Huge existing interceptor stack with no Query plan?
    • Keep HttpClient for now; migrate reads gradually

Checklist ​

  • [ ] provideFetchClient({ client: api }) in app.config / bootstrap
  • [ ] Every queryFn / mutationFn forwards { signal }
  • [ ] Errors inspected with isFetchError when you need status
  • [ ] Refresh via plugin instead of a one-off interceptor race
  • [ ] SSE from tanstack-fetch/sse (not bare EventSource) when auth matters
  • [ ] npx tanstack-fetch doctor --dir ./src clean before merge

Try it ​

bash
npm install tanstack-fetch @tanstack/angular-query-experimental
npx tanstack-fetch migrate --from axios --framework angular --write
npx tanstack-fetch doctor --dir ./src

Series ​

Same deep-dive format for every stack:

MIT Licensed · Created by Mohammad Garmabi · Not an official TanStack package