ehsan.blog
~/blog/three-important-concepts-of-developerehsan-api-client-v1 — zsh
cat three-important-concepts-of-developerehsan-api-client-v1.md

The three concepts you need before you write a single line of api-client

·11 min read

Most people skip the mental model and go straight to copy-pasting a config object. I get it, that’s usually faster. But api-client has exactly three ideas underneath it, and if you understand those three ideas first, every config option in later posts will feel obvious instead of magic. This is part two of the series — if you haven’t read part one, the short version is: I got tired of rebuilding the same fetch wrapper in every project, so I built this instead.

The problem this whole library exists to solve

Here’s what a typical app looks like before something like this exists. You’ve got a UserProfile component that fetches a user, a UserSettings component that fetches the same user, and an AdminPanel that also needs it but with a different timeout because admin endpoints are slower.

ts
// UserProfile.tsx
const res = await fetch(`https://api.example.com/users/${id}`, {
  headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
})
if (!res.ok) throw new Error('failed')
const user = await res.json()

// UserSettings.tsx — same thing, copy-pasted, slightly different
const res2 = await fetch(`https://api.example.com/users/${id}`, {
  headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
})
// ...someone forgot the !res2.ok check here

// AdminPanel.tsx — needs a longer timeout, nobody added it consistently
const controller = new AbortController()
setTimeout(() => controller.abort(), 30_000)
const res3 = await fetch(`https://api.example.com/users/${id}`, {
  signal: controller.signal,
  headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
})

Three call sites, three slightly different implementations, and if the token expires mid-session, all three break in three different ways because none of them know how to refresh it. This isn’t a hypothetical — this is what every fetch-based codebase I’ve worked in looks like by month two.

The fix isn’t “write better fetch calls.” It’s “stop writing fetch calls at the component level entirely.” That’s the whole premise. You describe your API once, and every component just calls a typed method.

ts
const user = await api.users.get(id)

Same auth, same timeout, same retry behavior, same caching, everywhere, automatically. That one-liner is doing everything the three messy blocks above were trying to do — plus catching bugs they didn’t even know they had.

Concept 1 — the client

The client is the thing you create exactly once, usually in a file like src/api.ts, and import everywhere else. It holds your global settings (base URL, auth strategy, default timeout, cache config) and it holds shared state — the cache itself, the in-flight request map used for deduplication, the concurrency queue.

That last part matters more than it sounds like it should. Because the client holds shared state, two different components calling the exact same endpoint at the exact same moment don’t fire two network requests — the second one just waits for the first one’s result. You get that for free, just by having one client instance instead of one fetch call per component.

Problem it solves: duplicate in-flight requests, inconsistent config across call sites, and “where do I even put the auth header logic” confusion.

How: one createClient() call, imported everywhere, never recreated per component.

ts
// src/api.ts — created once
import { createClient } from '@developerehsan/api-client'

export const api = createClient({
  baseURL: 'https://api.example.com',
  auth: { strategy: 'bearer', getToken: () => localStorage.getItem('access_token') },
})
ts
// anywhere else in your app
import { api } from './api'

A mistake I made early on: creating the client inside a component, thinking of it like any other object. Don’t do that — you lose the shared cache and dedup state, and you’re basically back to plain fetch with extra steps.

Concept 2 — modules and methods

A module is just a named group of related endpoints — users, invoices, orders. You declare one with defineModule, and inside it you write methods, which are the actual functions your app calls.

Problem it solves: without modules, you either end up with one giant flat object of every endpoint (api.getUser, api.getUserSettings, api.updateUserSettings…) or you’re back to scattering raw URLs through your codebase. Modules give you a namespace that mirrors how your backend is actually organized.

ts
import { defineModule } from '@developerehsan/api-client'

const users = defineModule({
  methods: {
    get: async (ctx, id: string) =>
      (await ctx.request({ method: 'GET', path: '/users/{id}', pathParams: { id } })).data,

    list: async (ctx, params?: { page?: number }) =>
      (await ctx.request({ method: 'GET', path: '/users', query: params })).data,

    create: async (ctx, body: { name: string; email: string }) =>
      (await ctx.request({ method: 'POST', path: '/users', body })).data,
  },
})

Notice every method’s first argument is ctx — that’s the module context, and it’s injected for you automatically. You never pass it yourself. Callers just do api.users.get('user_42'); the library slots ctx in as the first argument behind the scenes. This trips people up exactly once — the method signature has ctx as the first parameter, but the call site never mentions it.

ctx.request(spec) is the one primitive every method eventually calls. It takes an HTTP method, a path (with {placeholders} for path params), and optional query/body — and it’s the doorway into concept three.

A quick problem/solution on path params

Problem: you write path: '/orders/{orderId}/lines/{lineId}' but forget to pass lineId in pathParams.

Solution: the library throws a ConfigurationError before any network call happens — not a confusing 404 from a malformed URL, not a silent undefined in the path string. You find out immediately, at the call site, with a clear error. That’s a small thing, but it’s the difference between a five-second fix and a twenty-minute “why is this URL wrong” debugging session.

Concept 3 — the pipeline

This is the one that actually does the work. Every single ctx.request(...) call — no matter which method, which module, which part of your app — flows through the same ordered sequence of steps before a response comes back.

flowchart TD
    A[Your call: api.users.get] --> B[Merge config:<br/>global to module to per-call]
    B --> C[Concurrency queue]
    C --> D[Dedup check]
    D --> E{Cache hit?}
    E -->|Yes, fresh| F[Return cached data]
    E -->|No, or stale| G[Resolve tenant and auth headers]
    G --> H[Send request with timeout]
    H --> I{Failed?}
    I -->|5xx, network, timeout| J[Retry with backoff]
    J --> H
    I -->|401| K[Refresh token, retry once]
    K --> H
    I -->|No| L[Validate response, if enabled]
    L --> M[Write to cache]
    M --> N[Return typed response]

Every box on that diagram is a real, separate feature — caching, dedup, retries, auth refresh, validation — and every one of them is going to get its own full post later in this series. What matters right now is just the order, because the order is what makes the whole thing safe by default.

Problem it solves: in a hand-rolled setup, these steps get implemented inconsistently — one dev adds retries to their fetch call, another forgets, someone adds caching that doesn’t account for auth changing. Here, the order is fixed and shared by every single request in your app, so you can’t accidentally skip a step.

Concrete example of why order matters: cache lookup happens after dedup but before auth resolution finishes. That’s deliberate — the cache key needs to include an auth fingerprint (so two users never share a cached response), but you don’t want to do a full auth resolution for a request that’s about to be served from cache anyway. Get that ordering wrong in a hand-rolled implementation and you get subtle cross-user data leaks — which is exactly the kind of bug that’s cheap to write and expensive to find in production.

Installation

Nothing exotic here, three peer-dependency decisions and you’re done.

bash
# pick your package manager
pnpm add @developerehsan/api-client
npm  install @developerehsan/api-client
yarn add @developerehsan/api-client

The library ships with zero required dependencies bundled in — you opt into what you need, and unused pieces add zero bytes to your build:

bash
# Axios adapter (the default). Skip it entirely if you're fine running on fetch.
pnpm add axios

# TanStack Query integration — install only the framework entry you use
pnpm add @developerehsan/api-client-query @tanstack/react-query

You need TypeScript 5+ with strict mode (strongly recommended, not just tolerated), and Node 18+ — or any modern browser, or an edge runtime like Vercel Edge or Cloudflare Workers. That last part matters more than it sounds: the library detects edge environments automatically and switches to the fetch adapter so it never tries to load Axios somewhere Axios can’t run.

Your first client, actually explained line by line

Here’s the canonical “five minute” example, and instead of just pasting it, I want to walk through why each line exists.

ts
// src/api.ts
import { createClient, defineModule } from '@developerehsan/api-client'

export const api = createClient({
  baseURL: 'https://api.example.com',

  openapi: { mode: 'runtime' },

  auth: {
    strategy: 'bearer',
    getToken: () => localStorage.getItem('access_token'),
  },

  http: { timeout: 10_000, retry: { attempts: 3 } },
  cache: { strategy: 'stale-while-revalidate', ttl: 60_000 },

  modules: {
    users: defineModule({
      methods: {
        list: async (ctx, params?: { page?: number }) =>
          (await ctx.request({ method: 'GET', path: '/users', query: params })).data,

        get: async (ctx, id: string) =>
          (await ctx.request({ method: 'GET', path: '/users/{id}', pathParams: { id } })).data,

        create: async (ctx, body: { name: string; email: string }) =>
          (await ctx.request({ method: 'POST', path: '/users', body })).data,
      },
    }),
  },
})
  • baseURL — every path you write later (/users, /users/{id}) gets joined onto this. Change it once here, not in forty scattered strings.
  • openapi: { mode: 'runtime' } — this is the simplest possible mode. It means the client can fetch your OpenAPI spec at runtime for response validation, without you having to run a codegen step first. Later posts cover codegen mode, which trades this simplicity for compile-time type safety — worth it once your API stabilizes, overkill on day one.
  • auth.getToken — this function is called before every authenticated request. It can be sync or async, which matters if your token lives in secure storage that requires a read.
  • http.timeout and retry.attempts — defaults for every request in the app, unless a specific module or call overrides them. This is the setting that would have caught the AdminPanel timeout bug from the intro example — you set it once, centrally, instead of remembering it per component.
  • cache.strategy: 'stale-while-revalidate' — return a cached response immediately if one exists, refresh it in the background, and keep serving the stale copy if the background refresh fails. Good default for most UI data; later posts cover when cache-first or network-first are the better call.

And using it, anywhere else in the app:

ts
import { api } from './src/api'

const users = await api.users.list({ page: 1 })
const user  = await api.users.get('user_42')
const made  = await api.users.create({ name: 'Ada', email: 'ada@x.com' })

That’s it. That’s a fully working client with auth, retries, timeouts, caching, and deduplication already active on every single call — not because you wired each of those up individually, but because they’re properties of the pipeline every request already flows through.

The mistake almost everyone makes on day one

Problem: you write a method, call it, and get api.myModule.myMethod is not a function.

Why it happens: people assume that because the library supports OpenAPI codegen, the codegen step generates runtime methods too — so they expect a method to “just exist” once they’ve pointed the client at a spec.

Solution: it doesn’t work that way, and understanding why ties back directly to concept two. Codegen generates types and a module descriptor map — compile-time safety and metadata for the TanStack Query integration. The actual runtime methods, the functions your code calls, are always the ones you write with defineModule, usually as thin wrappers around ctx.request(...) that reference the generated paths. This is a deliberate design choice: it keeps runtime behavior explicit and debuggable. If a method exists, you can always find it, because you wrote it — nothing is conjured from a spec file behind your back.

What sticks from this post

Three ideas, and everything later in this series is really just detail underneath them:

  • The client is created once and holds shared state — that’s what makes dedup and caching actually work across your whole app instead of per component.
  • Modules and methods are your typed, organized entry points — ctx is injected, you never pass it, and ctx.request(...) is the one primitive underneath every method you’ll ever write.
  • The pipeline is a fixed, ordered sequence every request goes through — queue, dedup, cache, auth, dispatch, retry, validate, return — and the order is what makes the defaults safe instead of just convenient.

Next up in the series: authentication, all four strategies, and the OAuth2 refresh flow in enough detail to actually trust it in production — including the sequence diagram for how concurrent 401s get coalesced into a single refresh instead of racing each other.

  • developerehsan
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.