ApiClient — HTTP Client with Caching
A typed HTTP client: endpoints, tag-based caching, request-state subscriptions, abort.
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
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.
// ─── 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.
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
// 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) // trueQueryOptions — request options
// 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
},
}RequestDefinition — describing an endpoint's request
// The full structure of the object returned from request()
request: (params) => ({
path: '/pokemon', // path (appended to baseUrl)
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
})Caching and tags
// 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'
})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.
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
// 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) // PokemonApiResponseIn the effects this same endpoint is wrapped in
fromRequest(this.api.getDetails.request(...))— an RxJS wrapper overRequestResponseModify. More in Effects.
waitWithCallbacks() — callbacks per status
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
// 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 requestsubscribe() — subscribing to the endpoint state
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
// 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:
pokemonApiClientis created inpokemon.api.ts, initialized in the factory's async prologue (pokemon.synapse.ts), and its endpoints are passed intoPokemonEffects. The full assembly is on the Pokemon example page.
Using React? The
ApiClientis standalone (no RxJS/createSynapserequired), butsynapse-storage/reactships thin hooks over the endpoints so you don't have to wirerequest()/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).
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.
const cached = endpoints.getDetails.getCachedSync({ id: 25 })
if (cached?.ok) console.log(cached.data) // instant, from cacheSSR: 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.
// --- 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 calldehydrate(): Promise<TCacheState>— a snapshot of the cache (symmetric todehydrateModulefor synapse modules).hydrate(state)— seeds the cache. Called beforeinit()it stashes the snapshot and applies it right after the storage is created (soinit()won't overwrite the server state); called afterinit()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 viaexcludeCacheableHeaderKeys.