Synapsev7.0.0

ApiClient — HTTP Client with Caching

A typed HTTP client: endpoints, tag-based caching, request-state subscriptions, abort.

Why

You describe the endpoints once (path, method, param and response types, tags) — and get a typed request(), caching out of the box, and precise tag-based invalidation after mutations. The client is self-contained: it doesn't pull in RxJS/createSynapse/React, and it works on the server too (SSR via dehydrate/hydrate). It feels close to RTK Query, but without Redux.

When to use

  • You need an HTTP layer with caching and tag-based invalidation (after a mutation the active requests "come back to life").

  • You want type-safe endpoints and a single place for mapping the raw response into domain types.

  • SSR: warm up the cache on the server and hand it to the client with no loading flash (getCachedSync).

When you DON'T need it

  • One or two one-off fetches with no cache/invalidation — a native fetch is simpler, the client is overkill.

  • The data is already cached by a ServiceWorker and a second layer isn't needed — see Caching layers.

New to the caching stack? Start with Caching layers — where the client's cache sits relative to a ServiceWorker and fetchFn, and when to use which.

This page is built around the real pokemon.api.ts file from the pokemon-advanced example. The same pokemonApiClient is later used in the effects (see Effects) — it's the first brick of the data layer.

Imports

typescript
import { ApiClient } from 'synapse-storage/api'
import { MemoryStorage } from 'synapse-storage/core'

Creating the ApiClient (pokemon.api.ts)

pokemon.api.ts is a single responsibility of the module: configuring the client, describing the endpoints, and mapping the raw response into domain types. Nothing extra.

typescript
// ─── Raw API response types ─────────────────────────────────────────────────
// They describe the shape PokeAPI returns — which we hide behind mappers.

interface PokemonListApiResponse {
  count: number
  next: string | null
  results: Array<{ name: string; url: string }>
}

interface PokemonApiResponse {
  id: number
  name: string
  types: Array<{ type: { name: string } }>
  stats: Array<{ stat: { name: string }; base_stat: number }>
  abilities: Array<{ ability: { name: string } }>
  sprites: { front_default: string }
  height: number
  weight: number
}

// ─── ApiClient ──────────────────────────────────────────────────────────────

export const pokemonApiClient = new ApiClient({
  // The request-cache storage (required).
  storage: new MemoryStorage<Record<string, any>>({
    name: 'pokemon-advanced-api-cache',
    initialState: {},
  }),

  baseQuery: {
    baseUrl: 'https://pokeapi.co/api/v2',
    timeout: 10000,                       // request timeout (ms)
  },

  cache: {
    ttl: 60000,                           // global cache time-to-live (ms)
    invalidateOnError: true,              // invalidate cache on error
  },

  endpoints: async (create) => ({
    // GET — a list with query params
    getList: create<{ limit: number; offset: number }, PokemonListApiResponse>({
      request: (params) => ({
        path: '/pokemon',
        method: 'GET',
        query: params,                    // -> ?limit=12&offset=0
      }),
      cache: { ttl: 120000 },             // its own TTL for this endpoint
      tags: ['pokemon-list'],             // tags for invalidation
    }),

    // GET — a single resource by ID (param in the path)
    getDetails: create<{ id: number }, PokemonApiResponse>({
      request: ({ id }) => ({
        path: `/pokemon/${id}`,           // -> /pokemon/25
        method: 'GET',
      }),
      cache: true,                        // uses the global TTL
      tags: ['pokemon-details'],
    }),
  }),
})

// Client initialization (see "Lifecycle" below).
export const initPokemonApi = () => pokemonApiClient.init()

// The endpoints-set type — passed into the effects as a service.
export type PokemonApiEndpoints = ReturnType<typeof pokemonApiClient.getEndpoints>

Response mappers

Endpoints return the raw API response. Mappers turn it into domain types (PokemonBrief / PokemonDetails from pokemon.types.ts) so the rest of the data layer works only with a clean domain shape.

typescript
import type { PokemonBrief, PokemonDetails } from './pokemon.types'

export function mapListResponse(data: PokemonListApiResponse): { list: PokemonBrief[]; hasMore: boolean } {
  const list: PokemonBrief[] = data.results.map((p) => {
    // PokeAPI doesn't return an id in the list — we pull it from the url.
    const id = parseInt(p.url.split('/').filter(Boolean).pop()!)
    return {
      id,
      name: p.name,
      sprite: `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${id}.png`,
    }
  })
  return { list, hasMore: !!data.next }
}

export function mapDetailsResponse(data: PokemonApiResponse): PokemonDetails {
  return {
    id: data.id,
    name: data.name,
    types: data.types.map((t) => t.type.name),
    stats: data.stats.map((s) => ({ name: s.stat.name, value: s.base_stat })),
    abilities: data.abilities.map((a) => a.ability.name),
    sprite: data.sprites.front_default,
    height: data.height,
    weight: data.weight,
  }
}

Why the mappers live here, not in the effects: the API response shape is a transport detail. Keeping mappers next to the endpoints localizes the knowledge of PokeAPI in one file; effects and selectors only ever see the domain types.

request() — performing a request

typescript
// pokemonApiClient.request(endpointName, params, options?)
// Returns Promise<QueryResult<T>>

const result = await pokemonApiClient.request('getList', { limit: 12, offset: 0 })

// QueryResult<T>:
// {
//   ok: boolean           — whether the request succeeded
//   data?: T              — the response data (typed)
//   error?: Error         — the error (if ok = false)
//   status: number        — HTTP status (200, 404, ...)
//   statusText: string    — HTTP status text
//   headers: Headers      — response headers
//   fromCache?: boolean   — was the result from cache?
// }

if (result.ok) {
  console.log(result.data)        // PokemonListApiResponse (typed)
  console.log(result.fromCache)   // false (first request)
}

// A repeat request with the same params — from cache
const cached = await pokemonApiClient.request('getList', { limit: 12, offset: 0 })
console.log(cached.fromCache)     // true

QueryOptions — request options

typescript
// The third argument of request() — options
await pokemonApiClient.request('getDetails', { id: 25 }, {
  disableCache: true,             // bypass the cache
  timeout: 5000,                  // timeout for this request
  signal: abortController.signal, // abort signal
  headers: new Headers({          // extra headers
    'X-Custom': 'value',
  }),
  context: { source: 'user' },   // passed into prepareHeaders
})

// prepareHeaders receives the context:
baseQuery: {
  baseUrl: 'https://pokeapi.co/api/v2',
  prepareHeaders: async (headers, context) => {
    headers.set('Accept', 'application/json')
    if (context.context?.source === 'admin') headers.set('X-Admin', 'true')
    // context.requestParams        — the current request's params
    // context.getFromStorage('key') — read from storage
    // context.getCookie('name')     — read a cookie
    return headers
  },
}

If prepareHeaders throws (e.g. the token refresh failed), the request fails with ApiError (meta.status === 0, the thrown error in originalError) and is not sent — a request silently sent without auth headers is worse than a failed one.

RequestDefinition — describing an endpoint's request

typescript
// The full structure of the object returned from request()
request: (params) => ({
  path: '/pokemon',               // path (appended to baseUrl); an absolute URL (https://…) bypasses baseUrl —
                                  // prepareHeaders (incl. the auth token) is applied to it too: never build it from untrusted input
  method: 'GET',                  // 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
  body: params,                   // request body (POST/PUT/PATCH)
  query: { limit: 12 },          // query params (?limit=12)
  headers: { 'X-Custom': '1' },  // headers for this request
  responseFormat: 'json',         // 'json' | 'blob' | 'arrayBuffer' | 'text' | 'formData' | 'raw'
})

// A mutation example (POST with body + cache invalidation):
createPokemon: create<{ name: string; type: string }, PokemonApiResponse>({
  request: (params) => ({
    path: '/pokemon',
    method: 'POST',
    body: params,                 // serialized to JSON
  }),
  invalidatesTags: ['pokemon-list'],  // on success, drops the list cache
  cache: false,                       // don't cache mutations
})

query is a subset of the endpoint's params (Partial<Params>): path params go into path, and query holds only the rest. Key names and value types are still checked:

typescript
getMessages: create<{ chatId: number; limit: number; before?: number }, MessagesResponse>({
  request: ({ chatId, limit, before }) => ({
    path: `/chats/${chatId}/messages`, // chatId goes into the path
    method: 'GET',
    query: { limit, before },          // no chatId — fine
  }),
}),

Caching and tags

typescript
// Global cache (for all endpoints)
cache: {
  ttl: 60000,                            // 60 seconds
  invalidateOnError: true,
}

// Cache for a specific endpoint (overrides the global one)
getList: create<...>({
  cache: { ttl: 120000 },               // 2 minutes for the list
})

// Disable cache for an endpoint
createPokemon: create<...>({
  cache: false,
})

// Disable cache for a specific request
await pokemonApiClient.request('getDetails', { id: 1 }, {
  disableCache: true,                    // forced network request
})

// --- Tags ---
// An endpoint is tagged:
getList: create<...>({
  tags: ['pokemon-list'],
})

// A mutation invalidates tags on success:
createPokemon: create<...>({
  invalidatesTags: ['pokemon-list'],     // drops the cache of all endpoints
                                         // tagged 'pokemon-list'
})

How the cache behaves:

  • TTL by default — 5 minutes. A global cache object without ttl extends the defaults (it no longer means "forever").

  • Cache key = endpoint name + the full request params (nested objects and arrays included, key order doesn't matter)

    • the headers listed in cacheableHeaderKeys. Header values are hashed: a token in cacheableHeaderKeys: ['authorization'] separates per-user records but never lands in localStorage/IndexedDB or in the dehydrate() snapshot as plain text.

  • Parallel identical requests are deduplicated into one fetch. Aborting one of them doesn't affect the others; the shared fetch is cancelled only when every caller has aborted.

  • A request that was in flight while its tags got invalidated (a mutation finished in the meantime) returns its result to the caller but is not written to the cache — a stale response can't overwrite fresh data.

  • The key also includes the final path and responseFormat — if request(params, context) builds a different path from options.context, those are different records.

  • Binary responses (Blob, ArrayBuffer, Raw, FormData) are not cached — a JSON storage (localStorage, dehydrate) would silently turn them into {}.

  • invalidateOnError (on by default): a failed forced refetch (disableCache: true) drops the stored record of that key, so stale data isn't served after the server reported an error.

All client options (commented)

The whole surface of new ApiClient({...}) at once — what you can pass and why:

typescript
import { ApiClient } from 'synapse-storage/api'
import { MemoryStorage } from 'synapse-storage/core'

export const client = new ApiClient({
  // 1. storage — REQUIRED. The request-cache storage. An instance OR a factory () => IStorage
  //    (the factory is called on init() — handy for universal SSR code: Memory on the server, anything on the client).
  storage: () => new MemoryStorage({ name: 'api-cache', initialState: {} }),

  // 2. baseQuery — REQUIRED. The default transport for all endpoints.
  baseQuery: {
    baseUrl: 'https://api.example.com',        // base URL, the endpoint's path is glued onto it
    timeout: 10000,                            // overall request timeout (ms)
    credentials: 'include',                    // RequestCredentials for fetch (cookies)
    fetchFn: customFetch,                      // your own fetch function (e.g. via a ServiceWorker) — see CustomFetchFnExample
    prepareHeaders: async (headers, ctx) => {  // mutate headers before sending
      headers.set('Accept', 'application/json')
      // ctx.requestParams / ctx.context / ctx.getFromStorage(key) / ctx.getCookie(name)
      return headers
    },
  },

  // 3. cache? — the global cache policy. false disables the cache entirely (even if endpoints set it).
  cache: {
    ttl: 60000,                                // record lifetime (ms)
    invalidateOnError: true,                   // drop the endpoint's cache when a request errors
  },

  // 4. cacheableHeaderKeys? — headers that AFFECT the cache key (globally).
  //    Important for SSR: the set must match on the server and the client, otherwise hydration "misses".
  cacheableHeaderKeys: ['x-lang'],

  // 5. retry? — the global retry policy. Applies only to idempotent methods (GET/HEAD/OPTIONS/PUT/DELETE):
  //    retrying a POST/PATCH after a 5xx or a dropped connection could create the entity twice.
  //    `retry` on an endpoint (or in QueryOptions) is an explicit opt-in and works for any method.
  retry: {
    count: 3,                                  // number of retries (0 = no retry)
    delay: (attempt) => attempt * 500,         // delay: a number (ms) or (attempt) => ms
    retryOn: [0, 429, 500, 502, 503, 504],     // which HTTP statuses to retry on
  },

  // 6. endpoints — the endpoints factory. create<Params, Result>({ request, cache?, tags?, invalidatesTags?, retry? })
  endpoints: async (create) => ({
    getList: create<{ limit: number }, ListResponse>({
      request: (p) => ({ path: '/items', method: 'GET', query: p }),
      cache: { ttl: 120000 },                  // this endpoint's TTL (overrides the global one); cache:true — use the global; cache:false — don't cache
      tags: ['items'],                         // record tags (for invalidation)
      // invalidatesTags: ['items'],           // for mutations: which tags to drop on success
    }),
  }),
})
FieldTypeRequiredDescription
storageIStorage | () => IStorageyesThe cache storage (an instance or a lazy factory for SSR).
baseQueryFetchBaseQueryArgsyesbaseUrl (+ timeout/credentials/fetchFn/prepareHeaders).
cache?CacheConfig | falsenoGlobal ttl/invalidateOnError; false — turn the cache off completely.
cacheableHeaderKeys?string[]noHeaders that affect the cache key (critical for SSR stability).
retry?RetryConfignoDefault count/delay/retryOn for all endpoints.
endpoints?(create) => Promise<T>noEndpoint descriptions via create<Params, Result>({...}).

getEndpoints() — direct access to the endpoints

This is the form the data layer hands to the effects: pokemonApiClient.getEndpoints() returns an object with typed endpoints (getList, getDetails), and the PokemonApiEndpoints type is passed into PokemonEffects through the constructor.

typescript
const endpoints = pokemonApiClient.getEndpoints()

// The endpoint object:
// {
//   fetchCounts: number              — number of performed requests
//   request(params, options?)        — perform a request (returns RequestResponseModify)
//   subscribe(callback)              — subscribe to the endpoint state
//   reset()                          — reset the counter and drop THIS endpoint's cache records (not other endpoints' with the same tags)
//   meta: { name, tags, invalidatesTags, cache }
//   destroy()                        — cleanup
// }

// request() through an endpoint returns RequestResponseModify:
const req = endpoints.getDetails.request({ id: 25 })

// RequestResponseModify:
// {
//   id: string                       — the unique request ID
//   subscribe(listener)              — subscribe to the request state
//   wait()                           — Promise<QueryResult>
//   waitWithCallbacks({ idle, loading, success, error })
//   abort()                          — abort the request
//   then/catch/finally               — Promise API (awaitable)
// }

// Subscribe to the request state
req.subscribe((state) => {
  // RequestState<T>:
  // {
  //   status: 'idle' | 'loading' | 'success' | 'error'
  //   data?: PokemonApiResponse
  //   error?: Error
  //   fromCache: boolean
  //   requestParams: { id: number }
  // }
  console.log(state.status)       // 'loading' -> 'success'
  console.log(state.data)         // PokemonApiResponse | undefined
})

// Wait for the result
const result = await req.wait()
console.log(result.data)          // PokemonApiResponse

In the effects this same endpoint is wrapped in fromRequest(this.api.getDetails.request(...)) — an RxJS wrapper over RequestResponseModify. More in Effects.

waitWithCallbacks() — callbacks per status

typescript
const endpoints = pokemonApiClient.getEndpoints()
const req = endpoints.getDetails.request({ id: 25 })

const result = await req.waitWithCallbacks({
  idle: (state) => console.log('Idle'),
  loading: (state) => console.log('Loading...'),
  success: (data, state) => console.log('Data:', data),
  error: (error, state) => console.log('Error:', error),
})

// result — the same QueryResult<T>

abort() — aborting a request

typescript
// Way 1: through the endpoint
const endpoints = pokemonApiClient.getEndpoints()
const req = endpoints.getDetails.request({ id: 25 })
req.abort()  // aborts the request via AbortController

// Way 2: through an AbortController in the options
const controller = new AbortController()
const result = pokemonApiClient.request('getDetails', { id: 25 }, {
  signal: controller.signal,
})
controller.abort()  // aborts the request

An aborted request (at any point — before the headers arrive or while the body is being read) rejects with an AbortError, and the request status becomes 'error'. An abort is not logged as an error and never yields ok: true with empty data.

Errors and empty responses

  • 204 / empty body is a success. ok: true, data: undefined (for an endpoint declared as create<Params, void>()).

  • The library doesn't log request errors — they are delivered to the caller (reject / error state), who decides what to do.

  • Any failure rejects with ApiError (import { ApiError } from 'synapse-storage/api', also re-exported from synapse-storage/reactive): HTTP 4xx/5xx, a network error or timeout (meta.status === 0), a connection dropped while the body is being read. The same ApiError lands in the request state (error), in endpoint subscribers and in useApiQuery/useApiMutation.

typescript
try {
  await pokemonApiClient.request('getDetails', { id: 1 })
} catch (err) {
  if (err instanceof ApiError) {
    err.meta.status        // 404 (0 — network / timeout)
    err.originalError      // server response body (parsed JSON / text / undefined) or the original JS error
    err.message            // `message` from the body, otherwise "Request failed with status 404 Not Found"
  }
}

The timeout (timeout) covers the whole request — including reading the body — and actually aborts it.

subscribe() — subscribing to the endpoint state

typescript
const endpoints = pokemonApiClient.getEndpoints()

// Subscribe to the overall endpoint state (not a specific request)
const unsub = endpoints.getDetails.subscribe((state) => {
  // EndpointState:
  // {
  //   status: 'idle' | 'loading' | 'success' | 'error'
  //   error?: Error
  //   fetchCounts: number
  //   meta: { name, tags, invalidatesTags, cache }
  //   cacheableHeaders: string[]
  // }
  console.log('Endpoint status:', state.status, 'requests:', state.fetchCounts)
})

// Unsubscribe
unsub()

Lifecycle

typescript
// Initialization (required before use).
// In the module it's hidden inside initPokemonApi() and called from the createSynapse async factory:
await initPokemonApi()              // = pokemonApiClient.init()

// Destruction (clears the cache, subscriptions, endpoints)
await pokemonApiClient.destroy()

Where this comes together: pokemonApiClient is created in pokemon.api.ts, initialized in the factory's async prologue (pokemon.synapse.ts), and its endpoints are passed into PokemonEffects. The full assembly is on the Pokemon example page.

Using React? The ApiClient is standalone (no RxJS/createSynapse required), but synapse-storage/react ships thin hooks over the endpoints so you don't have to wire request() / subscribe() by hand. They live on their own pages: useApiQuery (GET) and useApiMutation (mutations). The sections below document the native endpoint/client surface those hooks are built on.

Cache invalidation bus: endpoint.onCacheInvalidate()

When a mutation succeeds with invalidatesTags, the matching cache entries are removed and an invalidation event is emitted. Endpoints whose tags intersect the invalidated tags get notified — this is what powers useApiQuery's auto-refetch (parity with React Query: after a mutation the active queries "come alive" instead of waiting for the TTL).

typescript
const endpoints = pokemonApiClient.getEndpoints()

// Re-run something when the endpoint's cache is invalidated
const unsub = endpoints.getList.onCacheInvalidate(() => {
  console.log('List cache invalidated — refetching')
})

// Triggered, for example, by a mutation that invalidatesTags: ['PokemonList']
unsub()

Synchronous cache read: endpoint.getCachedSync()

getCachedSync(params) reads a cached result synchronously, without a network request and without an async tick — so a hook can return server data on the very first render (no loading flash after hydration). It works only when:

  • the storage is synchronous (MemoryStorage / LocalStorage);

  • the endpoint has no headers that affect the cache key (the key can't be reproduced synchronously otherwise — headers are prepared async);

  • caching is enabled for the endpoint and the entry is not expired.

In any other case it returns undefined and the caller falls back to the regular async request(). useApiQuery uses this automatically for its initial render.

typescript
const cached = endpoints.getDetails.getCachedSync({ id: 25 })
if (cached?.ok) console.log(cached.data) // instant, from cache

SSR: dehydrate() / hydrate()

The client is not limited to the browser — the server path is real. On the server use MemoryStorage; on the client any storage will do. Cache timestamps are absolute (expiresAt = now + ttl), so they survive the server → client transfer, and the tag index is rebuilt on hydration.

typescript
// --- server ---
const api = new ApiClient({ storage: () => new MemoryStorage({ name: 'api-cache' }), baseQuery, endpoints })
await api.init()
await api.request('getList', { limit: 12, offset: 0 }) // warm up the cache
const dehydrated = await api.dehydrate()                // → serialize into HTML
await api.destroy()

// --- client ---
const api = new ApiClient({ storage: () => new MemoryStorage({ name: 'api-cache' }), baseQuery, endpoints })
await api.hydrate(dehydrated) // BEFORE init → seeds the cache (init won't overwrite it)
await api.init()
// first request('getList', sameParams) hits the cache — no network call
  • dehydrate(): Promise<TCacheState> — a snapshot of the cache (symmetric to dehydrateModule for synapse modules).

  • hydrate(state) — seeds the cache. Called before init() it stashes the snapshot and applies it right after the storage is created (so init() won't overwrite the server state); called after init() it replaces the cache state and rebuilds the tag index immediately.

  • getStorage() — access to the underlying cache storage instance (manual SSR/debugging).

Cache-key stability server ↔ client. The cache key includes cacheableHeaderKeys. If the set of cache-affecting headers differs between server and client (e.g. auth), the keys diverge and hydration "misses". For SSR endpoints exclude such headers from the key via excludeCacheableHeaderKeys.