Angular HttpClient vs tanstack-fetch
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 / injectMutation | tanstack-fetch |
Typed FetchError with status + body | tanstack-fetch |
SSE with Authorization + resume | tanstack-fetch/sse + useSse |
| Classic Angular apps with heavy RxJS pipes | HttpClient is fine |
| One HTTP client shared with React/Vue/Solid | tanstack-fetch |
npm install tanstack-fetch @tanstack/angular-query-experimentalMental model
HttpClient speaks Observables. TanStack Query speaks Promises + AbortSignal. Bridging them is possible — and noisy.
Contract that matters for Query:
- Success → return the parsed body (not
HttpResponse) - HTTP failure → throw (so
isErrorworks) - Cancel / route change → pass Query’s
signalthrough
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)
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)
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
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.
tanstack-fetchtypical HttpClient path
Feature matrix
| Capability | tanstack-fetch | Angular HttpClient |
|---|---|---|
Returns body for Query queryFn | Yes | Adapt (firstValueFrom + map) |
| Throws typed error with status/body | FetchError + isFetchError | HttpErrorResponse |
Native AbortSignal | First-class | Manual / limited |
Status handlers 401–429 | Built-in | Custom interceptor |
| Refresh token single-flight | Plugin | Custom |
Path params typing :id | Yes | Manual |
| Upload + progress | api.upload() | Yes |
SSE + Authorization | tanstack-fetch/sse | Not built-in |
| Angular signals SSE | useSse | DIY |
| tRPC transport | tanstack-fetch/trpc | DIY |
RxJS operators / HttpContext | Via bridge | Native |
| Bundle (HTTP core) | ~4.8KB gzip | Framework-included |
Auth, status handlers, refresh
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
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.
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
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
# 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:
- Create one
createFetchclient +provideFetchClient - Move read paths into
injectQueryfirst (highest Query win) - Port mutations
- Replace custom 401 refresh with the plugin
- Keep
HttpClientonly where RxJS pipelines are still the product
Suggested folder layout
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// src/app/api.ts
import { createFetch } from 'tanstack-fetch'
export const api = createFetch({
baseUrl: '/api',
getToken: () => localStorage.getItem('access_token'),
})// 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
- Is TanStack Query (or Angular Query) your source of truth for server state?
- Yes → start with
tanstack-fetch - No, pure RxJS streams everywhere →
HttpClientmay stay
- Yes → start with
- Do you need SSE with Authorization or resume?
- Yes →
tanstack-fetch/sse+useSse - No → either client works for REST
- Yes →
- Do React / Vue / Solid share the same backend client?
- Yes → one
createFetchacross packages - No → still fine; Angular DI adapters are optional
- Yes → one
- Huge existing interceptor stack with no Query plan?
- Keep HttpClient for now; migrate reads gradually
Checklist
- [ ]
provideFetchClient({ client: api })inapp.config/ bootstrap - [ ] Every
queryFn/mutationFnforwards{ signal } - [ ] Errors inspected with
isFetchErrorwhen you needstatus - [ ] Refresh via plugin instead of a one-off interceptor race
- [ ] SSE from
tanstack-fetch/sse(not bareEventSource) when auth matters - [ ]
npx tanstack-fetch doctor --dir ./srcclean before merge
Try it
npm install tanstack-fetch @tanstack/angular-query-experimental
npx tanstack-fetch migrate --from axios --framework angular --write
npx tanstack-fetch doctor --dir ./srcRelated
- Angular guide
- SSE · Angular
- tRPC · Angular
- Migrate CLI
- Comparison matrix
- 1.7.0 release — adapters + doctor
Series
Same deep-dive format for every stack:
