TanStack Query is excellent at managing server state. It handles caching, background refetching, invalidation, retries, mutations, and synchronization between components.
But managing server state and communicating with an API are two different responsibilities.
In a real application, you also need to handle:
- Expired access tokens and automatic refresh.
- Multiple requests failing with
401at the same time. - Different HTTP errors, from
400and403to429and500. - Typed request parameters, payloads, and responses.
- Request cancellation and cache synchronization.
- Live updates through Server-Sent Events (SSE).
This is where tanstack-fetch and TanStack Query work together.
- tanstack-fetch handles HTTP transport, authentication, typed errors, interceptors, and streaming.
- TanStack Query manages server state, caching, mutations, and synchronization.
- TypeScript and Zod help define and validate application contracts.
Let's build three realistic features for a financial operations dashboard: authentication with automatic token refresh, reliable API error handling, and type-safe order management.
1. Install the dependencies
npm install tanstack-fetch @tanstack/react-query zod
We'll use React with TypeScript and a Vite application. The examples use an API that returns JSON and an authentication system that provides a short-lived access token and an HTTP-only refresh cookie.
2. Create a shared API client
Instead of implementing authentication headers and error handling in every feature, create a single HTTP client.
// src/lib/api.ts
import { createFetch } from 'tanstack-fetch'
export const api = createFetch({
baseUrl: import.meta.env.VITE_API_URL,
credentials: 'include',
getToken: () => localStorage.getItem('access_token'),
})
This is enough for a basic application. However, a production application needs a consistent strategy for handling expired tokens and HTTP errors.
We'll extend this client in the next section.
One important design principle: the HTTP client should not know about React Query's cache, query keys, or components. It should handle HTTP concerns and return data or throw a meaningful error.
3. Scenario one: Automatic token refresh
Imagine that a user opens an account dashboard. Several requests run in parallel:
GET /accounts
GET /transactions
GET /profile
GET /notifications
The access token expires just before these requests reach the API.
All four requests receive 401 Unauthorized.
A naive implementation might start four refresh requests. Other implementations retry requests indefinitely or accidentally refresh when the server returns 403 Forbidden.
We want a different result:
- Detect an expired access token.
- Start one refresh operation.
- Make concurrent requests wait for that operation.
- Store the new access token.
- Retry each eligible original request once.
- Redirect to login if refreshing fails.
Implement the refresh-token interceptor
tanstack-fetch provides createRefreshTokenInterceptor from tanstack-fetch/plugins.
// src/lib/api.ts
import { createFetch } from 'tanstack-fetch'
import {
createRefreshTokenInterceptor,
} from 'tanstack-fetch/plugins'
type RefreshResponse = {
accessToken: string
expiresIn: number
}
let accessToken: string | null =
localStorage.getItem('access_token')
let expiresAt = Number(
localStorage.getItem('access_expires_at') ?? 0,
)
function persistToken(
token: string,
expiresInSeconds: number,
) {
accessToken = token
expiresAt = Date.now() + expiresInSeconds * 1000
localStorage.setItem('access_token', token)
localStorage.setItem(
'access_expires_at',
String(expiresAt),
)
}
function clearSession() {
accessToken = null
expiresAt = 0
localStorage.removeItem('access_token')
localStorage.removeItem('access_expires_at')
if (window.location.pathname !== '/login') {
window.location.assign('/login')
}
}
export const api = createFetch({
baseUrl: import.meta.env.VITE_API_URL,
credentials: 'include',
getToken: () => accessToken,
onUnauthorized: clearSession,
})
api.use(
'refresh-token',
createRefreshTokenInterceptor({
refresh: async () => {
const result = await api.post<RefreshResponse>(
'/auth/refresh',
{
interceptors: {
eject: ['refresh-token', 'auth'],
},
},
)
persistToken(
result.accessToken,
result.expiresIn,
)
},
// Refresh before the token expires.
before: {
getExpiresAt: () => expiresAt,
skewMs: 60_000,
},
// Also recover from an unexpected 401.
after: {
enabled: true,
},
}),
)
This example assumes that /auth/refresh accepts the refresh cookie and returns an access token with its lifetime. Adapt the response type to your backend's actual contract.
The interceptor's before strategy refreshes the token one minute before expiration. Its after strategy handles an unexpected 401 by refreshing and retrying the original request.
The refresh request explicitly ejects the refresh-token and auth interceptors. This prevents the refresh request from recursively triggering its own refresh flow. The plugin also coordinates concurrent refresh operations through a shared in-flight promise.
See the refresh-token recipe for the complete documented configuration.
Why does this matter?
Consider five concurrent queries that receive 401.
Without coordinated refresh, each query may independently call /auth/refresh. Depending on the backend, this can invalidate rotating refresh tokens, create race conditions, or cause unnecessary load.
With the interceptor, eligible requests share one refresh operation instead of implementing their own token-refresh queues.
Also note that this example is browser-specific. In Next.js or another SSR environment, don't access localStorage or window during server initialization. Use a request-scoped authentication strategy and the appropriate cookie-forwarding configuration instead.
401 is not 403
These responses mean different things:
| Status | Meaning | Expected behavior |
|---|---|---|
401 |
Authentication is missing or invalid | Attempt the configured refresh flow |
403 |
The user lacks permission | Show an authorization error |
404 |
Resource not found | Show a not-found state |
429 |
Rate limit exceeded | Apply an appropriate backoff |
500 |
Server failure | Show a recoverable error when appropriate |
A 403 should not automatically trigger token refresh or logout. Refresh is an authentication recovery mechanism, not a general response to every failed request.
4. Scenario two: Handle HTTP errors properly
Now let's build an API layer that handles different failures consistently.
A common problem with ordinary fetch is that receiving a 404 or 500 does not automatically reject the promise. The application must inspect the response and throw its own error.
tanstack-fetch is designed to return successful response data and throw a typed FetchError for HTTP failures. It also provides isFetchError for narrowing errors safely.
Configure status-specific behavior
Extend the client configuration with handlers for important HTTP statuses:
// src/lib/api.ts
import {
createFetch,
parseRetryAfter,
} from 'tanstack-fetch'
export const api = createFetch({
baseUrl: import.meta.env.VITE_API_URL,
getToken: () => accessToken,
onUnauthorized: clearSession,
onForbidden: () => {
console.warn('The user lacks permission.')
},
onNotFound: () => {
console.warn('The requested resource was not found.')
},
onTooManyRequests: ({ context }) => ({
action: 'retry',
delayMs: parseRetryAfter(
context.response?.headers,
2_000,
),
}),
onServerError: ({ status }) => {
console.error(`API server error: ${status}`)
},
})
The example illustrates the status-handler API. In the final client, retain the credentials and refresh-token interceptor configuration from the previous section rather than creating a separate client.
The 429 handler uses Retry-After when available and a two-second fallback otherwise. Retry only when the operation is safe to repeat and the backend's rate-limit policy permits it.
For a 500, logging is useful, but logging alone is not a user-facing error strategy. The component should still show a suitable fallback state.
Display typed errors in React
Let's create a reusable error message component.
// src/components/ApiError.tsx
import {
isFetchError,
} from 'tanstack-fetch'
type Props = {
error: Error | null
}
export function ApiError({ error }: Props) {
if (!error) return null
if (isFetchError(error)) {
switch (error.status) {
case 400:
return <p>Invalid request.</p>
case 401:
return <p>Your session has expired.</p>
case 403:
return <p>You do not have permission.</p>
case 404:
return <p>The requested resource was not found.</p>
case 429:
return <p>Too many requests. Please try again.</p>
default:
if (error.status >= 500) {
return <p>The server is temporarily unavailable.</p>
}
}
}
return <p>Something went wrong. Please try again.</p>
}
This example uses the documented isFetchError type guard and status field. It deliberately avoids displaying raw server error bodies to users, because those bodies may contain internal details.
The benefit is that components can distinguish HTTP failures without duplicating low-level response parsing.
5. Connect the API client to TanStack Query
With the transport layer in place, the query function stays simple.
Let's fetch an account balance.
// src/features/accounts/accounts.api.ts
import { api } from '@/lib/api'
export interface Account {
id: string
balance: number
currency: string
}
export function getAccount(accountId: string) {
return api.get<Account>(
`/accounts/${accountId}`,
)
}
Define a reusable query option:
// src/features/accounts/accounts.queries.ts
import { queryOptions } from '@tanstack/react-query'
import { getAccount } from './accounts.api'
export const accountQuery = (accountId: string) =>
queryOptions({
queryKey: ['accounts', accountId],
queryFn: ({ signal }) =>
getAccount(accountId, { signal }),
staleTime: 30_000,
retry: (failureCount, error) => {
if (
error instanceof Error &&
'status' in error &&
typeof error.status === 'number' &&
error.status >= 400 &&
error.status < 500
) {
return false
}
return failureCount < 2
},
})
Here, the AbortSignal is passed to the HTTP request so that TanStack Query can cancel obsolete work. The retry policy avoids retrying ordinary client errors; refine it to match your API and authentication strategy.
In particular, authentication recovery belongs to the HTTP layer. Query retries should not create a second, competing refresh-token mechanism.
Use the query in a component:
import { useQuery } from '@tanstack/react-query'
import { accountQuery } from './accounts.queries'
import { ApiError } from '@/components/ApiError'
export function AccountBalance({
accountId,
}: {
accountId: string
}) {
const { data, isPending, error } = useQuery(
accountQuery(accountId),
)
if (isPending) {
return <p>Loading account...</p>
}
if (error) {
return <ApiError error={error} />
}
return (
<p>
{data.balance.toLocaleString()} {data.currency}
</p>
)
}
The query function doesn't need to know how tokens are refreshed or how HTTP responses are parsed. It calls the API function, and TanStack Query handles the server-state lifecycle.
6. Scenario three: Type-safe order management
Consider an operations dashboard where an employee reviews orders, filters them by status, and approves or rejects individual orders.
Define the domain contract first.
// src/features/orders/orders.types.ts
export type OrderStatus =
| 'pending'
| 'approved'
| 'rejected'
export interface Order {
id: string
status: OrderStatus
amount: number
}
export interface UpdateOrderInput {
status: 'approved' | 'rejected'
reason?: string
}
Fetch an order
// src/features/orders/orders.api.ts
import { api } from '@/lib/api'
import type {
Order,
UpdateOrderInput,
} from './orders.types'
export function getOrder(orderId: string) {
return api.get<Order>(
`/orders/${orderId}`,
)
}
export function updateOrder(
orderId: string,
input: UpdateOrderInput,
) {
return api.patch<Order>(
`/orders/${orderId}`,
{ body: input },
)
}
These generic response types provide compile-time expectations. They do not validate the server's JSON at runtime, so validate untrusted responses at the API boundary if the application requires stronger guarantees.
Define the query
// src/features/orders/orders.queries.ts
import { queryOptions } from '@tanstack/react-query'
import { getOrder } from './orders.api'
export const orderQuery = (orderId: string) =>
queryOptions({
queryKey: ['orders', orderId],
queryFn: ({ signal }) =>
getOrder(orderId, { signal }),
})
The order ID is part of the query key because each order represents a distinct cached resource.
Update an order with a mutation
// src/features/orders/orders.mutations.ts
import {
useMutation,
useQueryClient,
} from '@tanstack/react-query'
import { updateOrder } from './orders.api'
import type {
UpdateOrderInput,
} from './orders.types'
export function useUpdateOrder(orderId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (input: UpdateOrderInput) =>
updateOrder(orderId, input),
onSuccess: (updatedOrder) => {
queryClient.setQueryData(
['orders', orderId],
updatedOrder,
)
queryClient.invalidateQueries({
queryKey: ['orders'],
})
},
})
}
Use it in an order review component:
const mutation = useUpdateOrder(orderId)
function approveOrder() {
mutation.mutate({
status: 'approved',
})
}
After a successful mutation, the individual order is updated immediately in the cache, and related order queries are invalidated.
The important separation remains unchanged: tanstack-fetch sends the request and returns typed data; TanStack Query synchronizes that data across the application.
7. Where does SSE fit?
Some dashboards need live transaction updates rather than periodic polling.
For example, a transaction might move from pending to completed while the user is looking at the page.
The initial transaction list belongs in TanStack Query. The SSE connection delivers subsequent changes.
With tanstack-fetch, authenticated SSE is available through the separate tanstack-fetch/sse entry point. Unlike native EventSource, a fetch-based SSE transport can use ordinary HTTP authorization headers.
A typical architecture looks like this:
Initial request
|
v
tanstack-fetch
|
v
TanStack Query cache
|
v
React components
SSE connection
|
v
Transaction event
|
v
Update or invalidate Query cache
|
v
React components
For each incoming event, either update the relevant cached transaction with queryClient.setQueryData or invalidate the affected query when the event does not contain enough information to safely update the cached representation.
Keep the stream's lifecycle separate from the query function. A query fetches a snapshot; an SSE subscription delivers ongoing events.
8. The responsibility matrix
| Concern | Appropriate layer |
|---|---|
| HTTP requests and base URL | tanstack-fetch |
| Access-token attachment | HTTP client |
Refresh and retry after 401
|
Refresh-token interceptor |
403, 404, 429, and 5xx handling |
HTTP status handlers and UI |
| Cache and background refetching | TanStack Query |
| Mutation lifecycle and invalidation | TanStack Query |
| Request cancellation | TanStack Query + AbortSignal
|
| TypeScript API contracts | TypeScript |
| Runtime payload validation | Zod or equivalent |
| Live event delivery | SSE transport |
| Synchronizing live data | TanStack Query cache |
Final thoughts
A production-ready data layer needs more than a convenient way to send a GET request.
It needs a clear boundary between HTTP transport and server-state management, predictable error semantics, coordinated authentication recovery, and explicit cache synchronization.
tanstack-fetch provides the HTTP-side building blocks, including typed FetchError handling and a refresh-token interceptor designed to handle concurrent refreshes. TanStack Query remains responsible for the lifecycle of server state.
The result is less duplicated request logic and a more consistent architecture as the application grows.
References
- tanstack-fetch documentation
- Refresh-token recipe
- Configuration and status handlers
- TanStack Query: Query Functions
- TanStack Query: Query Keys
Note: tanstack-fetch is an independent community package, not an official TanStack project.

Top comments (0)