Hooks vs events: shaping every request without touching every method
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.
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
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 resolvesEvents — 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.
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.
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