ehsan.blog
~/blog/api-client-part-10-hooks-events — zsh
cat api-client-part-10-hooks-events.md

Hooks vs events: shaping every request without touching every method

·4 min read

Somewhere around your fifteenth module, you’ll want to do something to every request — add a trace header, log every failure, show a global loading indicator. Editing fifteen modules’ worth of methods is the wrong answer. Hooks and events are the right one, and they’re not interchangeable — they solve different problems.

Hooks — you’re in the pipeline, you can change things

Problem: every outgoing request needs a correlation ID header for distributed tracing, and every response needs its timing logged. Doing this inside defineModule methods means duplicating the same two lines across every single method you ever write.

Solution: hooks run inside the pipeline, in order, and can modify the request or response as it passes through.

ts
createClient({
  hooks: {
    beforeRequest: (config) => {
      config.headers['X-Trace-Id'] = crypto.randomUUID()
      return config
    },
    afterResponse: (response) => {
      console.debug(`${response.status} in ${response.timing}ms`)
      return response
    },
    onError: (error) => {
      if (error instanceof AuthError) analytics.track('auth_error')
      throw error // re-throw, or return a fallback value to swallow it
    },
  },
})

beforeRequest and afterResponse both return the (possibly modified) value — that return is what continues down the pipeline. Forget to return config in beforeRequest and every request silently loses whatever that hook was supposed to add, which is an easy mistake to make once and annoying to debug, since nothing throws — the header just quietly isn’t there.

Where hooks sit in the pipeline

plaintext
your call


beforeRequest hook   ← can modify headers, add auth, log


dispatch, retry, etc (posts 5–7)


afterResponse hook   ← can transform or log the response


onError hook (only if it failed)  ← can swallow, rethrow, or track


your await resolves

Events — you’re watching, you can’t change anything

Problem: you want a global “X requests in flight” spinner, or you want to pipe every cache hit into your analytics for a dashboard on cache effectiveness. You don’t need to change the request — you need to know it happened.

Solution: the client is an event emitter. Listeners are read-only observers — they can’t modify the request or block it, which is exactly what you want for anything UI-adjacent, since you don’t want a debug listener accidentally breaking production requests.

ts
api.on('request:start', ({ key }) => incrementInFlightCount())
api.on('request:end', ({ key }) => decrementInFlightCount())
api.on('cache:hit', ({ key }) => analytics.track('cache_hit', { key }))
api.on('retry', ({ attempt, key }) => console.debug(`retry ${attempt} for ${key}`))
api.on('auth:refresh', () => console.debug('token refreshed'))

The rule of thumb: can you change it, or just watch it

I use this one question to decide which to reach for: does this need to modify the request or response, or just react to it happening? Adding a header — modifies, so it’s a hook. Logging to analytics — reacts, so it’s an event. Global loading spinner — reacts, event. Injecting an auth token — modifies, hook (though in practice this specific one is handled by the auth config from part five, not a hand-written hook).

Problem I’ve actually hit: writing a beforeRequest hook that just logs and returns the config unchanged, because I reached for a hook out of habit when an event listener would have been simpler and safer — a hook that forgets to return config breaks every request, an event listener that throws inside its handler doesn’t.

A worked example: request-scoped logging with both

Here’s where hooks and events actually work together, not as alternatives but as two halves of one observability setup.

ts
createClient({
  hooks: {
    beforeRequest: (config) => {
      config.headers['X-Trace-Id'] = crypto.randomUUID()
      config.startedAt = Date.now()
      return config
    },
  },
})

api.on('request:end', ({ key, status, config }) => {
  logger.info('request completed', {
    key,
    status,
    durationMs: Date.now() - config.startedAt,
    traceId: config.headers['X-Trace-Id'],
  })
})

The hook injects the trace ID and start time into the request — it’s changing what goes out. The event listener reads that same data back out after the fact — it’s watching, and it’s where the actual logging side effect lives, kept separate from anything that could affect the request itself.

What sticks from this post

  • Hooks (beforeRequest, afterResponse, onError) run inside the pipeline and can modify what continues through it — always return the value, or you’ll silently drop whatever the hook was meant to add.
  • Events (request:start, cache:hit, retry, etc.) are read-only observation — safe for anything UI-adjacent like spinners or analytics, because a listener can’t break a request.
  • The deciding question: does this need to change the request, or just react to it? That answer picks the tool.

Next: the codegen CLI — turning an OpenAPI spec into real generated types, and the commands that keep them from drifting out of sync with your backend.

  • 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.