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
baseUrlstring—Base URL for all requests. Relative paths work naturally.
headersRecord<string, string>—Default headers for all requests
getAccessToken() => string | null—Dynamic auth token getter
refreshAuth() => Promise<void>—Auth refresh function. Concurrent 401s share one refresh call.
timeoutnumber | false10000Request timeout in ms. false disables.
retryRetryOptions—Global retry configuration
onEvent(event) => void—Event 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.