Skip to content

API Client

createApiClient is the central API layer. It handles base URLs, dynamic headers, auth tokens, request cancellation, timeouts, retries, normalized errors, and an auth refresh queue.

Create a Client

ts
import { createApiClient } from "statemesh-core";

const api = createApiClient({
  baseUrl: "/api",
  getAccessToken: () => authStore.token,
  refreshAuth: () => authStore.refresh(),
  timeout: 10_000,
  retry: {
    attempts: 3,
    delay: ({ attempt }) => attempt * 500,
    retryOn: [408, 429, 500, 502, 503, 504],
    retryNetworkErrors: true,
    retryTimeouts: false,
    jitter: true
  },
  onEvent: (event) => {
    console.debug("[api]", event.type, event);
  }
});

Options

OptionTypeDefaultDescription
baseUrlstringBase URL for all requests. Relative paths work naturally.
headersRecord<string, string>Default headers for all requests
getAccessToken() => string | nullDynamic auth token getter
refreshAuth() => Promise<void>Auth refresh function. Concurrent 401s share one refresh call.
timeoutnumber | false10000Request timeout in ms. false disables.
retryRetryOptionsGlobal retry configuration
onEvent(event) => voidEvent hook for logging, analytics

HTTP Methods

ts
// GET
const products = await api.get<Product[]>("/products", {
  query: { search: "keyboard", page: 1 }
});

// POST
const product = await api.post<Product>("/products", {
  body: { name: "Keyboard", price: 99 }
});

// PUT
const updated = await api.put<Product>("/products/1", {
  body: { name: "Mechanical Keyboard" }
});

// PATCH
const patched = await api.patch<Product>("/products/1", {
  body: { price: 79 }
});

// DELETE
await api.delete("/products/1");

Per-Request Overrides

Every control can be overridden per request:

ts
api.get("/products", {
  timeout: false,
  retry: {
    attempts: 1,
    delay: 250,
    retryOn: (ctx) => ctx.status === 503
  },
  signal: abortController.signal,
  headers: { "X-Custom": "value" }
});

Upload

Send FormData, File, Blob, or other browser bodies:

ts
const formData = new FormData();
formData.append("avatar", file);

await api.upload<User>("/profile/avatar", formData, {
  onUploadProgress(progress) {
    console.log(`${progress.percent}% uploaded`);
  }
});

Auth Refresh Queue

When a 401 is received, the client calls refreshAuth() once and queues all concurrent requests. After the refresh completes, queued requests retry automatically:

ts
const api = createApiClient({
  baseUrl: "/api",
  getAccessToken: () => token,
  refreshAuth: async () => {
    const result = await fetch("/api/auth/refresh");
    token = result.accessToken;
  }
});

// Multiple concurrent 401s share one refresh call
const [user, products] = await Promise.all([
  api.get("/me"),
  api.get("/products")
]);

Relative Base URLs

ts
const api = createApiClient({ baseUrl: "/api" });
api.get("/products"); // Sends to /api/products

Error Handling

All errors are normalized to ApiClientError:

ts
import { ApiClientError, isApiClientError } from "statemesh-core";

try {
  await api.get("/protected");
} catch (error) {
  if (isApiClientError(error)) {
    console.log(error.status);    // 401
    console.log(error.message);   // "Unauthorized"
    console.log(error.headers);   // Response headers
  }
}

Events

The onEvent callback receives events for request lifecycle:

ts
onEvent: (event) => {
  switch (event.type) {
    case "api.request":     // Request started
    case "api.response":    // Response received
    case "api.error":       // Request failed
    case "api.retry":       // Retry attempted
    case "api.timeout":     // Request timed out
    case "api.auth_refresh": // Auth token refreshed
  }
}

Important Notes

TIP

Relative API bases work naturally in frontend apps. baseUrl: "/api" with api.get("/products") sends /api/products.

WARNING

Always pass an AbortSignal when the request can be cancelled (e.g., in a resource's fetch function or a transaction's effect). This prevents wasted network requests.

TIP

The retry configuration uses the same backoff() helper available for transactions: delay: backoff({ base: 500, max: 10000, jitter: true }).

Next Steps

Released under the MIT License.