Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Data Fetching Patterns: HttpClient vs. fetch / axios / TanStack Query

For .NET engineers who know: HttpClient, IHttpClientFactory, DelegatingHandler, System.Text.Json, typed HTTP clients, and Polly for resilience You’ll learn: How the JS/TS data fetching stack layers from raw fetch up through TanStack Query — and why TanStack Query’s caching model changes how you think about server state in the UI Time: 15-20 minutes

The .NET Way (What You Already Know)

In .NET, HttpClient is the standard HTTP client, registered through IHttpClientFactory to manage connection pooling and handle socket exhaustion. You configure it in Program.cs, inject it by interface, and it arrives typed and ready:

// Program.cs — register a typed HttpClient
builder.Services.AddHttpClient<IOrderApiClient, OrderApiClient>(client =>
{
    client.BaseAddress = new Uri("https://api.example.com");
    client.DefaultRequestHeaders.Add("Accept", "application/json");
})
.AddHttpMessageHandler<AuthTokenHandler>()  // DelegatingHandler for auth
.AddTransientHttpErrorPolicy(policy =>      // Polly: retry on 5xx and network errors
    policy.WaitAndRetryAsync(3, _ => TimeSpan.FromSeconds(1)));

// The typed client
public class OrderApiClient : IOrderApiClient
{
    private readonly HttpClient _client;

    public OrderApiClient(HttpClient client)
    {
        _client = client;
    }

    public async Task<Order?> GetOrderAsync(int id)
    {
        var response = await _client.GetAsync($"/api/orders/{id}");
        response.EnsureSuccessStatusCode(); // throws on 4xx, 5xx
        return await response.Content.ReadFromJsonAsync<Order>();
    }

    public async Task<IReadOnlyList<Order>> GetOrdersAsync(int userId)
    {
        return await _client.GetFromJsonAsync<List<Order>>($"/api/orders?userId={userId}")
            ?? [];
    }
}

// DelegatingHandler for auth tokens — middleware pattern for HttpClient
public class AuthTokenHandler : DelegatingHandler
{
    private readonly ITokenProvider _tokenProvider;

    public AuthTokenHandler(ITokenProvider tokenProvider)
    {
        _tokenProvider = tokenProvider;
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var token = await _tokenProvider.GetTokenAsync();
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
        return await base.SendAsync(request, cancellationToken);
    }
}

Key behaviors your code relies on:

  • EnsureSuccessStatusCode() throws on non-2xx responses — you always know when a request failed
  • ReadFromJsonAsync<T>() deserializes with System.Text.Json — typed deserialization
  • DelegatingHandler intercepts every request in the pipeline — one place for auth, logging, retry
  • Polly handles transient failures — retry policies configured once, applied everywhere

The JavaScript/TypeScript Way

The Foundation: The Fetch API

fetch is the browser’s built-in HTTP client, available in all modern browsers and in Node.js 18+. It is the lowest-level tool in the stack — the equivalent of creating an HttpClient instance directly without IHttpClientFactory, without typed deserialization, and without automatic error throwing.

// Basic GET request
const response = await fetch("https://api.example.com/orders/42");
const order: Order = await response.json();

// That looks simple. Here is what it does NOT do that HttpClient does:

The critical difference from HttpClient: fetch does not throw on HTTP errors. A 404, a 500, a 403 — all of these resolve successfully. The response.ok property and response.status tell you what happened, but you have to check them yourself.

// WRONG — this will not throw on 404 or 500
const response = await fetch("/api/orders/999");
const order = await response.json(); // may parse an error body, not an order

// CORRECT — check response.ok before deserializing
async function fetchOrder(id: number): Promise<Order> {
  const response = await fetch(`/api/orders/${id}`);

  if (!response.ok) {
    // response.status: 404, 500, 403, etc.
    // response.statusText: "Not Found", "Internal Server Error", etc.
    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  }

  return response.json() as Promise<Order>;
  // No automatic type validation — if the API returns wrong shape, TypeScript won't catch it at runtime
}
// POST with JSON body
async function createOrder(data: CreateOrderRequest): Promise<Order> {
  const response = await fetch("/api/orders", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify(data),
  });

  if (!response.ok) {
    const errorBody = await response.json().catch(() => ({}));
    throw new ApiError(response.status, errorBody.message ?? "Request failed");
  }

  return response.json();
}

fetch is low-level and verbose for production use. You can use it directly in server-side code (Next.js server components, API routes) where you control the environment, but in client-side code you almost always layer on top of it.

Axios: A Better HttpClient Wrapper

Axios is the closest analog to HttpClient in JavaScript — it wraps fetch (or XMLHttpRequest in older environments) and adds the behaviors you expect from .NET:

  • Throws on non-2xx responses automatically
  • Serializes request body to JSON automatically
  • Deserializes response body from JSON automatically
  • Interceptors equivalent to DelegatingHandler
  • Configurable base URL and default headers
npm install axios
// lib/api-client.ts — configured Axios instance
import axios, { AxiosError } from "axios";

export const apiClient = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL ?? "https://api.example.com",
  headers: {
    "Content-Type": "application/json",
  },
  timeout: 10_000, // 10 seconds — like HttpClient.Timeout
});

// Request interceptor — equivalent to DelegatingHandler for outgoing requests
apiClient.interceptors.request.use((config) => {
  const token = authStore.getToken();
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// Response interceptor — equivalent to DelegatingHandler for incoming responses
apiClient.interceptors.response.use(
  (response) => response, // pass through on success
  async (error: AxiosError) => {
    if (error.response?.status === 401) {
      // Token expired — try to refresh
      const refreshed = await authStore.refreshToken();
      if (refreshed && error.config) {
        // Retry the original request with the new token
        error.config.headers.Authorization = `Bearer ${authStore.getToken()}`;
        return apiClient.request(error.config);
      }
      authStore.logout();
    }
    return Promise.reject(error);
  }
);
// Typed API functions using the configured client
export const orderApi = {
  async getById(id: number): Promise<Order> {
    const { data } = await apiClient.get<Order>(`/orders/${id}`);
    return data;
    // axios throws on non-2xx — no manual response.ok check needed
    // data is typed as Order — but still no runtime type validation
  },

  async list(userId: number): Promise<Order[]> {
    const { data } = await apiClient.get<Order[]>("/orders", {
      params: { userId }, // appends as ?userId=42
    });
    return data;
  },

  async create(payload: CreateOrderRequest): Promise<Order> {
    const { data } = await apiClient.post<Order>("/orders", payload);
    return data;
  },

  async update(id: number, payload: UpdateOrderRequest): Promise<Order> {
    const { data } = await apiClient.put<Order>(`/orders/${id}`, payload);
    return data;
  },

  async delete(id: number): Promise<void> {
    await apiClient.delete(`/orders/${id}`);
  },
};

Axios is sufficient for server-to-server HTTP calls and for simple client applications. Where it falls short is managing server state in the UI — caching responses, knowing when data is stale, showing loading states, deduplicating parallel requests for the same data, and handling background refetching. That is where TanStack Query fits.

TanStack Query: Server State Management

TanStack Query (formerly React Query, with a Vue adapter called @tanstack/vue-query) is not an HTTP client. It sits above your HTTP client — you bring your own fetching function (Axios, fetch, or anything else) and TanStack Query manages what to do with the results:

  • Caches responses and returns the cached value immediately on subsequent requests
  • Deduplicates simultaneous requests for the same data (if ten components mount at once and all want /orders/42, only one HTTP request goes out)
  • Refetches in the background when the cached data is stale (configurable)
  • Manages loading, error, and success states
  • Handles optimistic updates (show the change immediately, roll back on failure)
  • Synchronizes with browser focus and network reconnection events
npm install @tanstack/react-query
# For Vue:
npm install @tanstack/vue-query

Setup

// app/providers.tsx — React setup (must wrap your application)
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 60 * 1000,       // data stays fresh for 1 minute
            gcTime: 5 * 60 * 1000,      // unused cache entries cleared after 5 minutes
            retry: 2,                    // retry failed queries twice (like Polly)
            refetchOnWindowFocus: true,  // refetch when user returns to the tab
          },
        },
      })
  );

  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

Queries: Reading Data

A query fetches and caches data. The queryKey is the cache key — an array that uniquely identifies this data. Think of it as the string key in IMemoryCache.GetOrCreateAsync(key, ...):

// hooks/useOrder.ts
import { useQuery } from "@tanstack/react-query";
import { orderApi } from "../lib/api-client";

export function useOrder(id: number) {
  return useQuery({
    queryKey: ["orders", id],    // cache key — ["orders", 42] is distinct from ["orders", 99]
    queryFn: () => orderApi.getById(id),
    enabled: id > 0,             // only fetch if we have a valid id
    staleTime: 30 * 1000,        // override default: this data stays fresh for 30s
  });
}

export function useOrders(userId: number) {
  return useQuery({
    queryKey: ["orders", { userId }],  // objects work — compared by deep equality
    queryFn: () => orderApi.list(userId),
  });
}
// components/OrderDetail.tsx — consuming the query
export function OrderDetail({ id }: { id: number }) {
  const { data: order, isLoading, isError, error, isFetching } = useOrder(id);
  // isLoading: true on first load (no cached data)
  // isFetching: true whenever a request is in-flight (including background refetches)
  // isError: true if the query failed
  // data: the Order, or undefined if not yet loaded

  if (isLoading) return <div>Loading order...</div>;
  if (isError) return <div>Error: {error.message}</div>;
  if (!order) return null;

  return (
    <div>
      <h1>Order #{order.id}</h1>
      {isFetching && <span>Refreshing...</span>}
      <p>{order.customerName}</p>
    </div>
  );
}

When two components both call useOrder(42) — say, a detail panel and a breadcrumb — TanStack Query deduplicates the request and shares the cached result. In .NET, you would manually implement this with IMemoryCache. Here it is automatic.

Stale-While-Revalidate

The caching model is stale-while-revalidate: data is served from cache immediately (fast), and if the cached data is older than staleTime, a background request is fired to refresh it. The user never waits for a spinner on subsequent visits to the same page.

// The lifecycle of a query:
//
// 1. First call: no cache → isLoading: true → HTTP request → data cached → isLoading: false
// 2. Same component re-mounts within staleTime: cache hit → data returned immediately, no request
// 3. Same component re-mounts after staleTime: cache hit → data returned immediately
//    AND a background request is fired (isFetching: true, isLoading: false)
// 4. User focuses the tab after being away: background refetch triggered

Mutations: Writing Data

A mutation is any operation that changes server state — POST, PUT, PATCH, DELETE. Mutations pair with cache invalidation: after a successful mutation, you tell TanStack Query to throw away cached data so it refetches fresh state:

// hooks/useCreateOrder.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { orderApi } from "../lib/api-client";
import type { CreateOrderRequest } from "../schemas/order.schema";

export function useCreateOrder() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (data: CreateOrderRequest) => orderApi.create(data),

    onSuccess: (newOrder) => {
      // Invalidate the orders list so it refetches with the new order included
      queryClient.invalidateQueries({ queryKey: ["orders"] });
      // This is like calling Response.Redirect() to refresh data after a POST

      // Optionally, seed the cache for the new order's detail page
      queryClient.setQueryData(["orders", newOrder.id], newOrder);
    },

    onError: (error) => {
      console.error("Order creation failed:", error);
    },
  });
}
// components/CreateOrderForm.tsx
export function CreateOrderForm() {
  const createOrder = useCreateOrder();

  const onSubmit = async (data: CreateOrderFormValues) => {
    await createOrder.mutateAsync(data);
    // mutateAsync throws on error — wraps in try/catch
    // mutate(data) is fire-and-forget — does not throw, use onError instead
    router.push(`/orders/${createOrder.data?.id}`);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* ... form fields ... */}
      <button type="submit" disabled={createOrder.isPending}>
        {createOrder.isPending ? "Creating..." : "Create Order"}
      </button>
      {createOrder.isError && (
        <div role="alert">{createOrder.error.message}</div>
      )}
    </form>
  );
}

Optimistic Updates

Optimistic updates show the change in the UI immediately before the server confirms it — then roll back if the server returns an error. This is the pattern that makes UIs feel responsive at any network speed.

export function useDeleteOrder() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (id: number) => orderApi.delete(id),

    onMutate: async (id) => {
      // Cancel any in-flight refetches (prevents race conditions)
      await queryClient.cancelQueries({ queryKey: ["orders"] });

      // Snapshot the current cache value
      const previousOrders = queryClient.getQueryData<Order[]>(["orders"]);

      // Optimistically update the cache — remove the order immediately
      queryClient.setQueryData<Order[]>(["orders"], (old) =>
        old?.filter((o) => o.id !== id) ?? []
      );

      // Return snapshot for rollback in onError
      return { previousOrders };
    },

    onError: (_error, _id, context) => {
      // Roll back to the snapshot if the mutation fails
      if (context?.previousOrders) {
        queryClient.setQueryData(["orders"], context.previousOrders);
      }
    },

    onSettled: () => {
      // Always refetch after mutation settles (success or failure)
      queryClient.invalidateQueries({ queryKey: ["orders"] });
    },
  });
}

Dependent Queries

Sometimes one query depends on the result of another — like fetching a user’s settings only after fetching the user:

function useUserSettings(userId: number | undefined) {
  const userQuery = useQuery({
    queryKey: ["users", userId],
    queryFn: () => userApi.getById(userId!),
    enabled: userId !== undefined,
  });

  const settingsQuery = useQuery({
    queryKey: ["settings", userQuery.data?.settingsId],
    queryFn: () => settingsApi.getById(userQuery.data!.settingsId),
    enabled: userQuery.data?.settingsId !== undefined, // only runs after user is loaded
  });

  return { user: userQuery.data, settings: settingsQuery.data };
}

In .NET you would await these sequentially in a service method. TanStack Query handles the dependency declaratively — you describe what depends on what, and it manages the sequencing and caching.

Vue Integration

// Nuxt / Vue 3 — setup in app.vue or a plugin
import { VueQueryPlugin, QueryClient } from "@tanstack/vue-query";

const queryClient = new QueryClient({
  defaultOptions: { queries: { staleTime: 60_000 } },
});
app.use(VueQueryPlugin, { queryClient });
<!-- components/OrderDetail.vue -->
<script setup lang="ts">
import { useQuery } from "@tanstack/vue-query";
import { orderApi } from "@/lib/api-client";

const props = defineProps<{ id: number }>();

const { data: order, isLoading, isError, error } = useQuery({
  queryKey: computed(() => ["orders", props.id]),
  queryFn: () => orderApi.getById(props.id),
});
</script>

<template>
  <div v-if="isLoading">Loading...</div>
  <div v-else-if="isError">Error: {{ error?.message }}</div>
  <div v-else-if="order">
    <h1>Order #{{ order.id }}</h1>
    <p>{{ order.customerName }}</p>
  </div>
</template>

Key Differences

Concept.NET HttpClientfetch APIAxiosTanStack Query
Error on non-2xxEnsureSuccessStatusCode() manualManual if (!response.ok)AutomaticVia your fetcher
JSON deserializationReadFromJsonAsync<T>()await response.json() (untyped){ data } (TypeScript typed)Via your fetcher
Request interceptorsDelegatingHandlerManual wrapperinterceptors.request.use()Not applicable
Response interceptorsDelegatingHandlerManual wrapperinterceptors.response.use()onError callbacks
Retry logicPollyManualAxios retry pluginBuilt-in retry option
CachingIMemoryCache (separate)NoneNoneBuilt-in, automatic
Loading stateManual bool loadingManualManualisLoading, isFetching
DeduplicationNoneNoneNoneAutomatic
Background refreshNoneNoneNonestaleTime + refetch
Optimistic updatesManualManualManualonMutate + rollback
Cache invalidationManualNoneNoneinvalidateQueries()
DI registrationIHttpClientFactoryModule importModule importQueryClientProvider

Gotchas for .NET Engineers

Gotcha 1: fetch does not throw on HTTP errors — ever

This is the single biggest footgun for engineers coming from HttpClient. In .NET, non-2xx responses throw HttpRequestException unless you catch it. With fetch, you check response.ok manually or you silently receive the error response body and try to parse it as your expected type.

// This compiles. It runs. It is wrong.
const response = await fetch("/api/orders/99999");
const order: Order = await response.json();
// If the response was 404, you just parsed { "type": "NotFound", "title": "Not Found", ... }
// as an Order. TypeScript won't warn you — the cast is just a lie at runtime.

console.log(order.customerName); // undefined — no runtime error, silent data corruption

The fix: always check response.ok, or use Axios, or write a wrapper:

async function apiFetch<T>(url: string, options?: RequestInit): Promise<T> {
  const response = await fetch(url, options);
  if (!response.ok) {
    const body = await response.json().catch(() => ({}));
    throw new ApiError(response.status, body.detail ?? response.statusText);
  }
  return response.json() as Promise<T>;
}

Gotcha 2: TypeScript generics on fetch/axios do not validate at runtime

axios.get<Order>(url) and (await fetch(url)).json() as Order are compile-time assertions only. If your API changes its response shape, TypeScript will not protect you at runtime. You get undefined on fields that no longer exist, or worse, old values on renamed fields.

// This type annotation is a promise to TypeScript, not a runtime guarantee
const { data } = await apiClient.get<Order>("/orders/42");
// If the API now returns { order_id instead of id }, data.id is undefined at runtime
// TypeScript sees it as Order and is satisfied — it trusted your generic

// Solution: parse with Zod for runtime safety
const rawData = await apiClient.get("/orders/42");
const order = orderSchema.parse(rawData.data); // throws ZodError if shape is wrong

For internal APIs where you control both sides, the TypeScript generic is usually sufficient — mismatches will be caught quickly. For third-party APIs or where the backend team is independent, add Zod parsing on the response.

Gotcha 3: Query keys must be stable — objects and arrays have reference equality gotchas

TanStack Query compares query keys using deep equality, so ["orders", { userId: 1 }] and ["orders", { userId: 1 }] are the same key even though they are different object references. However, keys must not include unstable references like function definitions or class instances:

// WRONG — a new object is created on every render, but TanStack Query handles it correctly
// (because it deep-compares) — this is fine for objects and primitives

// WRONG — including a function in the key is not meaningful and not comparable
queryKey: ["orders", filterFn]; // filterFn is a reference — do not do this

// CORRECT — use the function's outputs in the key, not the function itself
queryKey: ["orders", { status: "active", page: 1 }];

// WRONG — including a Date object without serializing it
queryKey: ["orders", new Date()]; // creates a new Date every render — causes refetch storm

// CORRECT — serialize the date
queryKey: ["orders", dateFilter.toISOString()];

A good convention: every key segment should be a string, number, boolean, or a plain object/array of those. No class instances, no functions.

Gotcha 4: invalidateQueries matches by prefix — invalidating too broadly is easy

queryClient.invalidateQueries({ queryKey: ["orders"] }) invalidates every query whose key starts with ["orders"]. This includes ["orders", 42], ["orders", { userId: 1 }], and ["orders", "recent"]. If you meant to only invalidate a specific order’s cache, be precise:

// Invalidates ALL orders queries — may trigger more refetches than expected
queryClient.invalidateQueries({ queryKey: ["orders"] });

// Invalidates only the specific order — use when you know exactly what changed
queryClient.invalidateQueries({ queryKey: ["orders", id], exact: true });

// Invalidates all user-specific orders lists
queryClient.invalidateQueries({ queryKey: ["orders", { userId }] });

After a successful create, invalidating all ["orders"] is usually correct — the list needs to show the new item. After a successful update to a specific order, exact: true on that order’s key is more efficient.

Gotcha 5: Mutations do not automatically invalidate the cache

Coming from .NET, you might expect that after a POST/PUT/DELETE, any component displaying related data would automatically refresh. TanStack Query does not do this automatically — you must invalidate the relevant queries in onSuccess. If you forget, the UI shows stale data indefinitely.

// WRONG — cache is not updated after mutation
useMutation({
  mutationFn: (data: UpdateOrderRequest) => orderApi.update(id, data),
  // No onSuccess — the UI still shows the old data after this runs
});

// CORRECT — invalidate related queries after mutation succeeds
useMutation({
  mutationFn: (data: UpdateOrderRequest) => orderApi.update(id, data),
  onSuccess: (updatedOrder) => {
    queryClient.invalidateQueries({ queryKey: ["orders"] });
    // Or, more surgical — set the cache directly to avoid a refetch
    queryClient.setQueryData(["orders", id], updatedOrder);
  },
});

Hands-On Exercise

Build a complete data layer for an order management feature using Axios + TanStack Query.

Setup:

npm install @tanstack/react-query @tanstack/react-query-devtools axios

Task 1 — Configure the Axios client in lib/api-client.ts:

  • Base URL from process.env.NEXT_PUBLIC_API_URL
  • Request interceptor that reads an auth token from localStorage (key: "auth-token") and adds Authorization: Bearer <token> header
  • Response interceptor that logs all 4xx and 5xx responses to console.error with the status code and URL
  • 10-second timeout

Task 2 — Write the query hooks in hooks/use-orders.ts:

// Implement these three hooks:
export function useOrders(userId: number) { ... }
// queryKey: ["orders", { userId }]
// staleTime: 2 minutes (order lists don't change that often)

export function useOrder(id: number) { ... }
// queryKey: ["orders", id]
// enabled: only when id > 0

export function useCreateOrder() { ... }
// On success: invalidate ["orders"] queries AND set the new order in cache
// Hint: use mutateAsync in your form, mutate if you don't need to await

Task 3 — Build the OrderList component that:

  • Shows a skeleton loader when isLoading is true (three placeholder <div> elements with a pulse animation)
  • Shows an error message with a “Try again” button that calls refetch() when isError is true
  • Shows the list of orders when data is available
  • Displays a subtle “Refreshing…” indicator when isFetching is true but isLoading is false (background refetch in progress)

Task 4 — Add optimistic deletion. When the user clicks Delete:

  • Remove the order from the cached list immediately
  • Fire the delete API call
  • If the call fails, restore the removed order and show an error message
  • If the call succeeds, refetch the list to confirm

Task 5 — Verify deduplication. Render <OrderDetail id={1} /> twice on the same page. Open the Network panel in browser DevTools. Navigate to the page and confirm that only one HTTP request is made, not two.

Quick Reference

TaskfetchAxiosTanStack Query
GET requestfetch(url).then(r => r.json())apiClient.get<T>(url)useQuery({ queryKey, queryFn })
POST requestfetch(url, { method: "POST", body })apiClient.post<T>(url, data)useMutation({ mutationFn })
Check for HTTP errorsif (!response.ok) throw ...AutomaticVia your queryFn
Add auth headerManual per-requestinterceptors.request.use()In your queryFn / Axios interceptor
Retry on failureManualaxios-retry packageretry: 3 in QueryClient defaults
Cache responseManualNoneAutomatic (staleTime)
Loading stateManualManualisLoading, isFetching
Error stateManual try/catchisAxiosError(err)isError, error
Invalidate cacheN/AN/AqueryClient.invalidateQueries()
Set cache directlyN/AN/AqueryClient.setQueryData()
Optimistic updateManualManualonMutate + return context
Rollback on failureManualManualonError(err, vars, context)
Deduplicate requestsManualManualAutomatic
Background refreshManualManualrefetchOnWindowFocus, staleTime
PrefetchManualManualqueryClient.prefetchQuery()
Cancel in-flight requestAbortControllerCancelToken / AbortSignalqueryClient.cancelQueries()

.NET to JS/TS concept map

.NET conceptJS/TS equivalent
HttpClientaxios instance or fetch
IHttpClientFactoryModule-level singleton (Axios instance)
DelegatingHandlerAxios request/response interceptor
EnsureSuccessStatusCode()if (!response.ok) throw / Axios default
ReadFromJsonAsync<T>()response.json() (no runtime validation)
GetFromJsonAsync<T>()apiClient.get<T>(url).then(r => r.data)
Polly retryTanStack Query retry option
Polly circuit breakerNo direct equivalent (use custom interceptor)
IMemoryCacheTanStack Query cache
IDistributedCacheNo direct equivalent in browser context
CancellationTokenAbortController / AbortSignal

Further Reading