skip to content

In urql, how would you write a mapExchange that adds a cart-session header and signs users out on authentication errors, and where does it go in exchanges?

level: seniorimportance: nice to knowfreq 14%

answer

  1. a pipeline stage, not a plugin
  2. three optional callbacks
  3. rewrite the operation's context
  4. after the cache, before fetch
  5. synchronous first, asynchronous last

basics

~20 s

mapExchange takes onOperation, onResult and onError. onOperation returns a copy of the operation with extra fetchOptions headers, onError inspects the CombinedError, and the exchange sits between cacheExchange and fetchExchange so it sees every request and its result.

solid answer

~40 s

`mapExchange` from `urql` wraps three optional callbacks. `onOperation` may return a replacement operation, so you build one with `makeOperation(op.kind, op, { ...op.context, fetchOptions })`, merging the new header into existing `fetchOptions`, which may be an object or a function. `onError(error, operation)` receives the `CombinedError`, where you check `graphQLErrors` for an `UNAUTHENTICATED` code or `error.response?.status === 401` and sign the user out. It goes after `cacheExchange` and before `fetchExchange`: placed after `fetchExchange` it never sees queries or mutations, because `fetchExchange` does not forward what it sends. urql's rule is synchronous exchanges first, asynchronous last; a `mapExchange` whose `onOperation` returns a promise becomes asynchronous, and in front of the cache it would delay cache hits. Token refresh with retry is `authExchange`'s job, not this one.

code

ts · 28 lines
ts
import { Client, cacheExchange, fetchExchange, makeOperation, mapExchange } from 'urql';

const cartSessionExchange = mapExchange({
  onOperation(operation) {
    const current =
      typeof operation.context.fetchOptions === 'function'
        ? operation.context.fetchOptions()
        : operation.context.fetchOptions || {};
    return makeOperation(operation.kind, operation, {
      ...operation.context,
      fetchOptions: {
        ...current,
        headers: { ...current.headers, 'x-cart-session': readCartSessionId() },
      },
    });
  },
  onError(error) {
    const expired = error.graphQLErrors.some(
      (e) => e.extensions?.code === 'UNAUTHENTICATED'
    );
    if (expired || error.response?.status === 401) signOut();
  },
});

export const client = new Client({
  url: '/graphql',
  exchanges: [cacheExchange, cartSessionExchange, fetchExchange],
});

go deeper

for a junior

Recall that urql features are exchanges in an array and that mapExchange offers onOperation, onResult and onError hooks for simple custom behaviour.

for a middle

Explain how onOperation returns a new operation via makeOperation, what a CombinedError contains, and why the header exchange must sit before fetchExchange.

for a senior

Order a real pipeline, cache, custom and auth exchanges, fetch, subscriptions, and justify it with the synchronous-first rule and fetchExchange's forwarding behaviour.

for a principal

Decide what belongs in shared exchanges versus components, and when to adopt authExchange or retryExchange instead of growing a hand-written mapExchange.

## What an exchange is An urql **exchange** is a function that receives the client's stream of **operations** (queries, mutations, subscriptions and `teardown` signals) and returns a stream of **results**. The `Client` composes the `exchanges` array into a pipeline: operations travel left to right, and results travel back right to left. Each exchange can pass an operation on with `forward`, answer it itself, or change it on the way. Writing a raw exchange means working with the stream library urql is built on, and it has rules: never drop operation kinds you do not handle, and forward what you do not answer. For the common case of "change each operation" or "react to each result", urql ships **`mapExchange`**, which hides the stream plumbing. ## mapExchange's three callbacks `mapExchange` replaced the older `errorExchange` in `@urql/core` 3.1; `errorExchange` survives only as a deprecated alias of it. | Callback | Receives | May return | Typical use | |---|---|---|---| | `onOperation` | each non-teardown `Operation` | a new `Operation`, a promise of one, or nothing | add headers, change context | | `onResult` | each `OperationResult` | a new result, a promise of one, or nothing | reshape or log results | | `onError` | `(error, operation)` when a result has an error | nothing | sign out, report, show a toast | The error is a **`CombinedError`**, urql's single error type, which carries `graphQLErrors` (the entries of the response's `errors` array), an optional `networkError`, and the HTTP `response` when there was one. ## Writing the storefront exchange The storefront keeps an anonymous cart session id and must send it on every request, and a session that has expired must send the shopper back to sign-in. urql treats operations as immutable values, so `onOperation` builds a copy with `makeOperation`: 1. Read the current `fetchOptions` from `operation.context`; it may be a plain object or a function that returns one. 2. Merge the new header into its `headers`. 3. Return `makeOperation(operation.kind, operation, { ...operation.context, fetchOptions })`. `onError` checks two places, because APIs signal an expired session differently: - a GraphQL error whose `extensions.code` is `UNAUTHENTICATED`, arriving in a normal HTTP 200 response; - an HTTP 401, visible as `error.response?.status`. Three details keep it correct in production: - If `onOperation` returns nothing, the original operation passes through unchanged, so a guard that skips some operations can simply return early. - Several queries can fail with the same expired session at once, and `onError` fires for each; make `signOut` idempotent or guard it with a flag. - The header is part of `fetchOptions`, so it reaches HTTP requests only; a subscription sent over a WebSocket carries its credentials through that transport's own connection setup. ## Where it goes in the array ```ts exchanges: [cacheExchange, cartSessionExchange, fetchExchange, subscriptionExchange({ forwardSubscription })] ``` - **After `cacheExchange`**: cache hits are answered without reaching it, which is fine because they need no header. - **Before `fetchExchange`**: every operation that will be sent passes through it first, and every network result passes back through it, so `onError` sees them. - **Not after `fetchExchange`**: `fetchExchange` sends queries and mutations and forwards only `teardown` and, unless `fetchSubscriptions` is set, subscriptions. An exchange placed after it never sees a query, so no header is added and `onError` never fires for them. - `subscriptionExchange` sits last for the same reason: it receives the subscriptions that `fetchExchange` forwards. ## Synchronous first, asynchronous last urql's docs ask for **synchronous exchanges first and asynchronous ones last**. urql is designed so that a cached result reaches a component in the same tick it asks for it, which lets the first render and Suspense mode use cached data without an extra render. A `mapExchange` whose callbacks return plain values is synchronous. If `onOperation` returns a promise, for example because it reads the session id from asynchronous storage, the exchange becomes asynchronous. Placed in front of `cacheExchange`, it would delay every operation, including those the cache could answer at once, so nothing would ever arrive synchronously from the cache. ## When mapExchange is not enough `mapExchange` cannot hold operations back while a token is refreshed, nor re-send the ones that failed. For that, `@urql/exchange-auth` provides `authExchange`, which takes `addAuthToOperation`, `didAuthError`, `refreshAuth` and an optional `willAuthError`, queues operations during a refresh and retries them. Transient network failures are `retryExchange`'s job. A small `mapExchange` stays the right tool for fixed headers, logging and a sign-out reaction.

  • Why not put this mapExchange first in the array, before cacheExchange?
    While its callbacks are synchronous it would work, but it would also run for operations the cache answers and for the results it returns from its store. If `onOperation` ever becomes asynchronous, it would delay every operation, including cache hits, so components lose synchronous cached data on first render and in Suspense mode. Between the cache and `fetchExchange` it touches only what goes to the network.
  • The session expires mid-checkout and you want to refresh the token and replay the failed mutation. Can mapExchange do that?
    Not cleanly. `onError` only observes the failed result; it cannot pause other operations or re-send the failed one. `authExchange` from `@urql/exchange-auth` exists for this: `didAuthError` detects the failure, `refreshAuth` obtains a new token while it queues operations, and `addAuthToOperation` applies the token before operations are retried.

saying these in an interview costs you the question

  • Exchange order does not matter because urql sorts exchanges by kind.
  • errorExchange is still the current way to react to urql errors.
  • onOperation's return value is ignored, so you must edit the operation in place.
  • An exchange placed after fetchExchange still sees every query.
  • mapExchange's onError can retry the failed operation after refreshing a token.