DEV Community

mohammad garmabi
mohammad garmabi

Posted on

Building Production-Ready React Data Flows with tanstack-fetch and TanStack Query

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 401 at the same time.
  • Different HTTP errors, from 400 and 403 to 429 and 500.
  • 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
Enter fullscreen mode Exit fullscreen mode

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

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

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:

  1. Detect an expired access token.
  2. Start one refresh operation.
  3. Make concurrent requests wait for that operation.
  4. Store the new access token.
  5. Retry each eligible original request once.
  6. 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,
    },
  }),
)
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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

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

Use it in an order review component:

const mutation = useUpdateOrder(orderId)

function approveOrder() {
  mutation.mutate({
    status: 'approved',
  })
}
Enter fullscreen mode Exit fullscreen mode

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

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

Note: tanstack-fetch is an independent community package, not an official TanStack project.

Top comments (0)