useOnRpcError

One place to react to rpc failures that are nobody's local problem — auth loss, telemetry, toasts.

Some failures do not belong to the component that triggered them. An Unauthenticated error means the session is gone, whatever screen you happen to be on. Rather than repeat that check in every useMutation call site, each api's logical mount carries an error bus; this hook subscribes to it with React lifecycle. Replacing the socket runtime does not replace that bus, so the listener stays attached through transport recovery and auth rebuilds.

import { useOnRpcError } from '@voltro/client'
import { errorTag } from '@voltro/protocol'

useOnRpcError('app', useCallback((e) => {
  if (e.kind === 'transport') {
    showConnectionLost()
    return
  }
  if (errorTag(e.error) === 'Unauthenticated') redirectToSignIn()
}, []))

useOnRpcError is the presentation channel. Non-transport failures arrive immediately. Transport failures wait for the recovery grace window; a gap that heals in time produces no callback, and an outage that outlives the window produces exactly one callback even if many subscriptions failed together. Use useConnectionStatus for the matching recovering / degraded banner state.

The event is { source, tag, error, kind, outcome?, traceId? }. source is 'mutation' | 'action' | 'subscription', so both unary calls and stream failures arrive here. error is the value the call failed with — the declared typed error, an RpcClientError for a transport failure, or the defect — and it is the same value the failing hook hands over (useSubscription's error, a rejected mutate), so one _tag switch serves both. errorTag(err) reads _tag off tagged errors and returns undefined otherwise.

kind is 'transport' | 'handler' | 'client' | 'unknown':

  • transport — @effect/rpc could not complete the exchange. Use this branch for connection banners and to suppress misleading data-error toasts.
  • handler — the rpc returned a declared failure, such as Unauthenticated.
  • client — framework work around the call failed locally.
  • unknown — a combined or opaque operation could not be classified honestly.

For unary calls, outcome says what can be proved about the server-side result: not-started, known, or unknown. A transport failure after sending is unknown even if a trace id exists — the server may have committed before the response disappeared. Subscription events use not-applicable.

traceId identifies the client call span. When the server accepted the request, it propagates the same id and voltro logs --trace <id> can join the call to server logs. A transport error can happen before the server saw the request, or after it handled the request but before the response returned, so a traceId on kind: 'transport' is not proof that server logs exist. For the same reason the event deliberately has no reachedServer boolean.

The callback is held in a ref, so an inline function does not tear down and rebuild the subscription on every render. The bus is per logical api mount — an app talking to several apis subscribes once per api name, not once per connection generation.

You do not need this hook to REPORT rpc failures. Every event this bus emits is also published on the client error bus, so a reporter — the Sentry browser integration, or anything wired with subscribeClientErrors — already sees it, under source: 'rpc.mutation' | 'rpc.action' | 'rpc.subscription' with the rpc tag, rpcErrorKind, and optional traceId in context. This hook is for cross-cutting policy (redirect on Unauthenticated, toast on a network failure), not for telemetry; bridging the two by hand now reports twice.

The reporting bus stays raw: every failure is published immediately, including a reconnect storm. If you need that same unfiltered stream in React, use useOnRawRpcError; do not use it for user-facing toasts.

import { useOnRawRpcError } from '@voltro/client'

useOnRawRpcError('app', (event) => diagnostics.record(event))

If that is too loud for your reporter, gate it in the subscriber — which failures are worth an event is your policy, and the bus cannot hold one for every subscriber.

This is the read side of failures that already happened on the wire. To push a client-side error the server never saw, call reportClientError(error, context) instead. Note also that a subscription failure after data arrived reaches only this bus, never the hook's error field — see Queries.