@emeryld/rrroutes-client
v2.10.12
Published
Typed React Query and Socket.IO client for RRRoutes contracts: endpoints, hooks, fetchers, cache keys, and socket bindings built directly from finalized leaves.
Readme
@emeryld/rrroutes-client
Typed React Query + Socket.IO helpers that sit on top of RRRoutes contracts. Build endpoints directly from finalized leaves, get strongly-typed hooks/fetchers, ready-to-use cache keys, debug logging, and optional socket utilities (client + React provider + socketed routes).
Installation
pnpm add @emeryld/rrroutes-clientMost apps also want React Query, which the hook-based entry points peer on:
pnpm add @emeryld/rrroutes-client @tanstack/react-query@emeryld/rrroutes-contract and zod come along as dependencies; you supply React Query.
Prerequisites
zod^4.0.0
React, TanStack Query, and Socket.IO are optional peers; install only those used by the selected entry points.
Entry points
Choose the smallest entry point needed by a shared client folder:
| Import path | Purpose | Runtime | Status | Additional requirements |
| --------------------------------------- | ----------------------------------------------------------------------------------------- | -------------- | ------ | ---------------------------------------------- |
| @emeryld/rrroutes-client | Typed React Query and Socket.IO helpers built on finalized RRRoutes contracts. | react, browser | stable | @tanstack/react-query, react |
| @emeryld/rrroutes-client/augments | Composable endpoint behavior and hook-time TanStack Query options. | react, browser | stable | @tanstack/react-query, react |
| @emeryld/rrroutes-client/bindings | Reusable typed factories that bind an application resource to route-client helpers. | react, browser | stable | @tanstack/react-query, react |
| @emeryld/rrroutes-client/http | Transport types, the default fetcher, and HttpError — no React required. | browser, node | stable | — |
| @emeryld/rrroutes-client/pagination | Application-wide feed pagination presets and cursor helpers. | universal | stable | — |
| @emeryld/rrroutes-client/react-query | Route client and React Query helpers for typed endpoints built from finalized leaves. | react, browser | stable | @tanstack/react-query, react |
| @emeryld/rrroutes-client/socket | Framework-independent Socket.IO client core for socketed RRRoutes resources. | browser, node | stable | socket.io-client |
| @emeryld/rrroutes-client/socket/cache | Immutable array and feed cache reducers for applying socket events to React Query caches. | browser | stable | @tanstack/react-query |
| @emeryld/rrroutes-client/socket/react | React provider and hooks for socketed RRRoutes resources. | react, browser | stable | react, socket.io-client, @tanstack/react-query |
Compatibility
Ships ESM + CJS with per-subpath type declarations. ./http and ./pagination
are framework-free and run in Node or the browser; ./socket runs in both but
needs socket.io-client. React is only required by the hook and provider entry
points.
Quick start (typed GET with React Query)
import { QueryClient } from '@tanstack/react-query';
import { createRouteClient } from '@emeryld/rrroutes-client';
import { registry } from '../routes'; // from @emeryld/rrroutes-contract + finalize(...)
const routeClient = createRouteClient({
baseUrl: '/api', // prepended to all paths
queryClient: new QueryClient(), // shared React Query instance
});
const listUsers = routeClient.build(registry.byKey['GET /v1/users'], {
staleTime: 60_000,
onReceive: (data) => console.log('fresh users', data),
});
export function Users() {
const { data, isLoading } = listUsers.useEndpoint({ query: { search: 'emery' } });
if (isLoading) return <p>Loading…</p>;
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}How it works
createRouteClientwires yourQueryClient, base URL, optional custom fetcher, and debug settings.dispatchEndpoint({ leaf, input, ...requestOptions })imperatively executes any leaf through that shared client configuration.build(leaf, options?, meta?)returns a helper that exposes:useEndpoint(args?, options?)— React hook for GET/feeds/mutations (typed params/query/body/output) with optional per-call TanStack options.fetch(...)— direct fetcher (no cache). Mutations require the body as the last argument.getQueryKeys(...)— deterministic cache key used by React Query + invalidation.invalidate(...)— invalidate this exact endpoint instance.setData(updater, args?)— mutate cache (infinite-aware).
- For feed endpoints (
cfg.feed === true), cursors are handled automatically; cache keys omit the cursor so pages merge correctly. - Feed endpoints automatically expose
useFeed; mutations automatically exposefetchMany,fetchManyWithOptions, anduseEndpointMany.
Application-specific endpoint behavior can be layered with the public augment API. See the endpoint augment authoring guide.
Detailed usage
1) Configure the client
The recommended setup is to export one configured client from your app's
shared client folder. Web, mobile, and embeds can import that same module while
the custom fetcher handles platform auth consistently. Per request,
fetchWithOptions(...) accepts headers, signal, and credentials; hook
queries forward TanStack Query's cancellation signal automatically. Custom
fetchers also receive the originating contract leaf as req.leaf.
import { QueryClient } from '@tanstack/react-query'
import { createRouteClient, defaultFetcher } from '@emeryld/rrroutes-client'
const queryClient = new QueryClient()
const client = createRouteClient({
baseUrl: 'https://api.example.com',
queryClient,
fetcher: async (req) => {
// Attach auth headers, reuse defaultFetcher for JSON parsing + error handling
return defaultFetcher({
...req,
headers: { ...req.headers, Authorization: `Bearer ${getToken()}` },
})
},
environment: process.env.NODE_ENV, // disables debug when "production"
debug: {
fetch: true,
invalidate: true,
verbose: true, // include params/query/output in debug events
},
})The client also exposes a leaf-aware dispatcher for integrations such as an MCP server, jobs, and other dynamic route registries:
const result = await client.dispatchEndpoint({
leaf: registry.byKey['PATCH /v1/users/:userId'],
input: {
params: { userId: 'user_123' },
query: { notify: true },
body: { displayName: 'Ada' },
},
signal: abortController.signal,
})It uses the same fetcher, authentication, request/response validation,
multipart conversion, and debug pipeline as helpers created with build.
Pass the client-visible error shape directly as the first generic:
import { createRouteClient } from '@emeryld/rrroutes-client/react-query'
import type { ApiError } from '../shared/errors'
export const client = createRouteClient<ApiError>({
baseUrl: API_URL,
queryClient,
fetcher: (req) => defaultFetcher(req),
})
const users = client.build(registry.byKey['GET /v1/users'])
// users.useEndpoint().error is ApiError | null
await users.fetchWithOptions(
{ credentials: 'omit', signal: abortController.signal },
{ query: { search: 'emery' } },
)If you prefer a typed client-wide hook error without passing one generic at client construction, augment the global RRRoutes client register once:
import { createRouteClient, type HttpError } from '@emeryld/rrroutes-client'
declare global {
namespace RRRoutesClient {
interface Register {
error: HttpError
}
}
}
const client = createRouteClient({
baseUrl: 'https://api.example.com',
queryClient,
})
const users = client.build(registry.byKey['GET /v1/users'])
const result = users.useEndpoint()
// result.error is now typed as HttpError | null2) Build a reusable shared client resource
Use defineRouteResource in application-shared code to select and name the
contract leaves. Then define the client behavior with createClientBindings.
The resource remains server-compatible through its byKey and all fields.
// shared/users.resource.ts
import { defineRouteResource } from '@emeryld/rrroutes-contract/resource'
export const usersResource = defineRouteResource(registry.byKey, {
listUsers: 'GET /v1/users',
getUser: 'GET /v1/users/:userId',
updateUser: 'PATCH /v1/users/:userId',
})For a complete networking resource, use object form and attach the socket contract once:
export const usersResource = defineRouteResource({
routes: registry.byKey,
keys: {
listUsers: 'GET /v1/users',
getUser: 'GET /v1/users/:userId',
updateUser: 'PATCH /v1/users/:userId',
},
sockets,
})Create the client socket layer without repeating its events or config:
import { createResourceSocketClient } from '@emeryld/rrroutes-client/socket'
import { buildResourceSocketProvider } from '@emeryld/rrroutes-client/socket/react'
const socketClient = createResourceSocketClient(usersResource, {
socket,
sys: socketSystemHandlers,
})
const socketReact = buildResourceSocketProvider(usersResource, {
options: { sys: socketSystemHandlers },
})// client/users.client.ts
import {
createClientBindings,
type ClientBindingAdapter,
} from '@emeryld/rrroutes-client/bindings'
import { withSocketRoute } from '@emeryld/rrroutes-client/socket/react'
import type { SocketClient } from '@emeryld/rrroutes-client/socket'
interface AppAdapter extends ClientBindingAdapter<ApiError> {
useSocketClient: () => SocketClient<AppEvents, AppSocketConfig> | null
}
const defineClientResource = createClientBindings<ApiError, AppAdapter>()
export const createUsersClient = defineClientResource({
resource: usersResource,
defaults: {
listUsers: { feed: { getItemId: (user) => user.id } },
updateUser: { singleton: true },
},
compose: {
getUser: (endpoint, { adapter }) =>
withSocketRoute(endpoint, {
id: 'users.get.live',
useSocketClient: adapter.useSocketClient,
toRooms: ({ args }) => ({
rooms: args?.params.userId ? [`users:${args.params.userId}`] : [],
}),
applySocket: {
'users:updated': ({ prev, payload }) =>
prev?.id === payload.id ? { ...prev, ...payload } : null,
},
roomStrategy: { kind: 'by-route-param', name: 'userId' },
cacheReducers: [
{ event: 'users:updated', kind: 'merge', name: 'merge-user' },
],
}),
},
})
export const users = createUsersClient({
routeClient: client,
useSocketClient,
finalize: (endpoint) => withApplicationCachePolicy(endpoint),
})ClientBindingAdapter requires only a configured route client's build
method. Extend it with socket hooks, current-user hooks, or platform services
needed by composition. Its optional finalize callback runs once per built
endpoint before resource composition.
roomStrategy and cacheReducers are inspection descriptors, not executable
callbacks. Their fields are JSON-safe so hosted inspectors, checkpoints, and
semantic diffs can expose them without serializing application runtime state.
Use compose for independent endpoint wrappers. Its context contains
adapter, all built routes, and the exact shared resource (including its
socket contract). Use decorate(routes, adapter, resource) when several
endpoints depend on each other or the returned resource
needs additional helpers. The two forms are mutually exclusive.
Build options are applied in this order:
- Definition
defaults. - Overrides passed to
createUsersClient(adapter, overrides). - Adapter
finalize. composeordecorate.
Nested feed and many options are merged. Other options are right-biased.
The inferred BuiltResource, ClientResourceOptions, and
ClientResourceDefinitionBase types are exported for framework adapters that
need to describe this surface explicitly.
3) Configure pagination once
Every feed: true leaf inherits the client-wide pagination policy. The
standard cursor preset supports forward, previous, and anchored page
descriptors:
import { createCursorPagination } from '@emeryld/rrroutes-client/pagination'
const pagination = createCursorPagination({
query: {
cursor: 'cursor',
direction: 'direction',
anchor: 'anchorId',
},
meta: {
nextCursor: 'nextCursor',
previousCursor: 'previousCursor',
},
})
const client = createRouteClient({
baseUrl: '/api',
queryClient,
pagination,
})Use defineFeedPagination({...}) for a custom reusable preset. A feed can
override the global policy with build option pagination: {...}, or disable
inheritance with pagination: false. Flat cursor options on the feed build
remain supported and take precedence for that endpoint.
4) Build individual endpoints from your registry
import { registry } from '../routes'
// Plain GET
const getUser = client.build(registry.byKey['GET /v1/users/:userId'], {
staleTime: 30_000,
})
// Infinite/feed GET (cfg.feed === true)
const listFeed = client.build(registry.byKey['GET /v1/posts'], {
cursorQueryKey: 'page', // defaults to "pagination_cursor"
getNextPageCursor: (last) => last.nextCursor, // cursor extraction override
})
// Mutation
const updateUser = client.build(registry.byKey['PATCH /v1/users/:userId'], {
onSuccess: () => client.invalidate(['get', 'v1', 'users']), // prefix invalidate
})5) Use GET hooks (with params/query/body)
type User = Awaited<ReturnType<typeof getUser.fetch>>; // fully typed output
function Profile({ userId }: { userId: string }) {
const result = getUser.useEndpoint({ params: { userId } });
if (result.isLoading) return <p>Loading…</p>;
if (result.error) return <p>Failed: {String(result.error)}</p>;
// Register a listener for push-based updates (e.g., sockets) against this hook
result.onReceive((freshUser) => {
console.log('pushed update', freshUser);
});
return <div>{result.data.name}</div>;
}- For GET leaves that define a
bodySchema, pass the body after the args tuple:
const auditStatus = client.build(registry.byKey['GET /v1/audit'])
await auditStatus.fetch({}, { includeExternal: true }) // body matches the leaf's bodySchema6) Use infinite feeds
function PostFeed() {
const feed = listFeed.useEndpoint({ query: { cursor: undefined, limit: 20 } });
return (
<>
{(feed.data?.pages ?? []).map((page) =>
page.items.map((post) => <article key={post.id}>{post.title}</article>),
)}
<button
disabled={!feed.hasNextPage || feed.isFetchingNextPage}
onClick={() => feed.fetchNextPage()}
>
Load more
</button>
</>
);
}- Cursor params are stripped from cache keys automatically so pages share the same base key.
- Infinite feeds may use object page descriptors with
pageParamToQuery,getPreviousPageParam, andgetNextPageParamfor bidirectional or anchored pagination. Object-valued initial page parameters are appended to the query key automatically, so different anchored windows do not share cached pages. Invalidating the base feed key also invalidates those anchored windows.
7) Use mutations (with optimistic cache helpers)
async function rename(userId: string, name: string) {
// Direct fetch (server action / non-React usage)
await updateUser.fetch({ params: { userId } }, { name });
}
export function RenameForm({ userId }: { userId: string }) {
const mutation = updateUser.useEndpoint({ params: { userId } });
async function submit(e: React.FormEvent) {
e.preventDefault();
const name = new FormData(e.currentTarget).get('name') as string;
// Optimistically update cache for both the detail and list
updateUser.setData((prev) => (prev ? { ...prev, name } : prev), { params: { userId } });
client.invalidate(['get', 'v1', 'users']);
await mutation.mutateAsync({ name });
}
return (
<form onSubmit={submit}>
<input name="name" defaultValue="" />
<button disabled={mutation.isLoading}>Save</button>
{mutation.error && <p>Error: {String(mutation.error)}</p>}
</form>
);
}All mutation leaves include batch methods automatically:
await updateUser.fetchMany({ params: { userId } }, [bodyA, bodyB])
const batch = updateUser.useEndpointMany(
{ params: { userId } },
{ onSuccess: (outputs, bodies) => console.log(outputs, bodies) },
)
await batch.mutateAsync([bodyA, bodyB])useEndpointMany is one aggregate TanStack mutation with one status/error and
an ordered output array. fetchMany is the imperative equivalent and does not
run TanStack lifecycle callbacks.
Set singleton: true at mutation build time to share status, data, and error
between hook instances with the same route params/query:
const requestLoginCode = client.build(loginCodeLeaf, { singleton: true })Bodies are not part of the singleton key. Use singleton state for a logical route action whose UI should agree across multiple components, not as a general mutation-result cache.
8) Cache keys, invalidation, and manual cache writes
const keys = getUser.getQueryKeys({ params: { userId: 'u_1' } }) // ['get','v1','users','u_1', {}]
await getUser.invalidate({ params: { userId: 'u_1' } }) // invalidate exact detail
await client.invalidate(['get', 'v1', 'users']) // invalidate any users endpoints
getUser.setData((prev) => (prev ? { ...prev, status: 'online' } : prev), {
params: { userId: 'u_1' },
})setData respects feeds (updates InfiniteData shape when cfg.feed === true).
9) Router helper (build by name instead of leaf)
import { buildRouter } from '@emeryld/rrroutes-client'
import { registry } from '../routes'
const routes = {
listUsers: registry.byKey['GET /v1/users'],
updateUser: registry.byKey['PATCH /v1/users/:userId'],
} as const
const buildRoute = buildRouter(client, routes)
const listUsers = buildRoute('listUsers') // builds from routes.listUsers
const updateUser = buildRoute('updateUser', {}, { name: 'profile' }) // debug name filtering10) Batch multiple built endpoints (buildBranch)
buildBranch lets you batch already-built endpoints behind one batch path.
const getUser = client.build(registry.byKey['GET /v1/users/:userId'])
const updateUser = client.build(registry.byKey['PATCH /v1/users/:userId'])
const userBatch = client.buildBranch(
{ getUser, updateUser },
{ path: '/v1/batch', method: 'post' }, // method defaults to POST
)
const result = await userBatch.fetch({
getUser: {
args: { params: { userId: 'u_1' } },
},
updateUser: {
args: { params: { userId: 'u_1' } },
body: { name: 'Emery' }, // required for mutation leaves
},
})
// result keys map back to your aliases:
// { getUser: ..., updateUser: ... }The request body sent to /v1/batch is keyed by URL-encoded leaf keys:
{
[encodeURIComponent('GET /v1/users/:userId')]: { params: { userId: 'u_1' } },
[encodeURIComponent('PATCH /v1/users/:userId')]: {
params: { userId: 'u_1' },
body: { name: 'Emery' },
},
}buildBranch also provides:
useEndpoint(input, options?)for a single React Query hook over the batched response.getQueryKeys(input?)to derive per-alias keys.invalidate(input?)to invalidate each leaf alias exactly.setData(input)to write cache per alias.
11) File uploads (FormData)
If a leaf has bodyFiles set in its contract, the client automatically converts
the body to FormData when at least one declared file field contains a file.
Calls without an actual file remain JSON requests.
For each declared file field name, pass files using file${name} in the input body.
The raw field name is also accepted for backward compatibility.
const uploadAvatar = client.build(
registry.byKey['PUT /v1/users/:userId/avatar'],
)
await uploadAvatar.fetch(
{ params: { userId: 'u_1' } },
{
// bodyFiles: [{ name: 'avatar', maxCount: 1 }]
fileavatar: new File([blob], 'avatar.png', { type: 'image/png' }),
// any non-file body fields still go here and are validated by bodySchema
note: 'profile image',
},
)For multi-file fields (maxCount > 1), pass Blob[] or FileList:
await uploadAvatar.fetch(
{ params: { userId: 'u_1' } },
{ filephotos: [fileA, fileB] }, // bodyFiles: [{ name: 'photos', maxCount: 5 }]
)12) Debug logging
const client = createRouteClient({
baseUrl: '/api',
queryClient,
debug: {
build: true,
fetch: true,
invalidate: true,
setData: true,
useEndpoint: true,
verbose: true,
// Limit to specific endpoints by name (third arg to build)
only: ['profile', 'feed'],
logger: (e) => console.info('[rrroutes-client]', e),
},
})
const profile = client.build(
registry.byKey['GET /v1/me'],
{},
{ name: 'profile' },
)Inspection tools can observe the same stream without enabling console logging:
const unsubscribe = client.subscribeDebug((event) => {
// fetch events share a requestId across start/fetched/parsed/error stages
diagnostics.push(event)
})Set environment: 'production' to silence all debug output regardless of the debug option.
Socket utilities (optional)
The package also ships a typed Socket.IO client, React provider hooks, and a helper to merge socket events into React Query caches.
Define events + config
import { z } from 'zod'
import { defineSocketEvents } from '@emeryld/rrroutes-contract'
const { events, config } = defineSocketEvents(
{
joinMetaMessage: z.object({ source: z.string().optional() }),
leaveMetaMessage: z.object({ source: z.string().optional() }),
pingPayload: z.object({
clientEcho: z.object({ sentAt: z.string() }).optional(),
}),
pongPayload: z.object({
clientEcho: z.object({ sentAt: z.string() }).optional(),
sinceMs: z.number().optional(),
}),
},
{
'chat:message': {
message: z.object({
roomId: z.string(),
text: z.string(),
userId: z.string(),
}),
},
},
)Vanilla SocketClient
import { io } from 'socket.io-client'
import { SocketClient } from '@emeryld/rrroutes-client'
import { events, config } from './socketContract'
const socket = io('https://socket.example.com', { transports: ['websocket'] })
const client = new SocketClient(events, {
socket,
config,
sys: {
'sys:ping': async () => ({
clientEcho: { sentAt: new Date().toISOString() },
}),
'sys:pong': async ({ payload }) => {
console.log('pong latency', payload.sinceMs)
},
'sys:room_join': async ({ rooms }) => {
console.log('joining rooms', rooms)
return true // allow join
},
'sys:room_leave': async ({ rooms }) => {
console.log('leaving rooms', rooms)
return true
},
},
heartbeat: { intervalMs: 15_000, timeoutMs: 7_500 },
debug: { receive: true, emit: true, verbose: true, logger: console.log },
})
client.on('chat:message', (payload, meta) => {
console.log('socket message', payload.text, 'latency', meta?.ctx?.latencyMs)
})
void client.emit('chat:message', {
roomId: 'general',
text: 'hi',
userId: 'u_1',
})Key methods: emit, on, joinRooms / leaveRooms, startHeartbeat / stopHeartbeat, connect / disconnect, stats(), destroy().
React provider + hooks
import { buildSocketProvider } from '@emeryld/rrroutes-client';
import { io } from 'socket.io-client';
import { events, config } from './socketContract';
const { SocketProvider, useSocketClient, useSocketConnection } = buildSocketProvider({
events,
options: {
config,
sys: {
'sys:ping': async () => ({ clientEcho: { sentAt: new Date().toISOString() } }),
'sys:pong': async () => {},
'sys:room_join': async () => true,
'sys:room_leave': async () => true,
},
heartbeat: { intervalMs: 10_000 },
debug: { receive: true, hook: true, logger: console.log },
},
});
function App({ children }: { children: React.ReactNode }) {
return (
<SocketProvider
getSocket={() => io('https://socket.example.com')}
destroyLeaveMeta={{ source: 'app:unmount' }}
fallback={<p>Connecting…</p>}
>
{children}
</SocketProvider>
);
}
function RoomMessages({ roomId }: { roomId: string }) {
const client = useSocketClient<typeof events, typeof config>();
useSocketConnection({
event: 'chat:message',
rooms: roomId,
joinMeta: { source: 'room-hydration' },
leaveMeta: { source: 'room-hydration' },
onMessage: (payload) => console.log('message for room', payload.text),
});
return (
<button onClick={() => client.emit('chat:message', { roomId, text: 'ping', userId: 'me' })}>
Send
</button>
);
}Socket + React Query: withSocketRoute
Automatically join rooms based on fetched data and patch the cache when socket messages arrive.
import { withSocketRoute } from '@emeryld/rrroutes-client/socket/react';
import { useSocketClient } from './socketProvider';
const listRooms = client.build(registry.byKey['GET /v1/rooms'], { staleTime: 120_000 });
const useAppSocketContext = () => ({ workspaceId: 'acme' });
const socketedRooms = withSocketRoute(listRooms, {
useContext: useAppSocketContext, // optional app-level context for hooks
toRooms: ({ data, meta }) => ({
rooms: data.items.map((r) => `${meta?.appContext?.workspaceId}:${r.id}`), // derive rooms from data (feeds supported)
joinMeta: { source: 'rooms:list' },
leaveMeta: { source: 'rooms:list' },
}),
useSocketClient,
applySocket: {
'chat:message': ({ prev, payload, args, meta }) => {
if (!prev) return null; // explicit no-op update
console.debug('socket patch args', args, meta.appContext?.workspaceId, meta.ctx?.socketId);
// Example: bump unread count in cache
const apply = (items: any[]) =>
items.map((room) =>
room.id === payload.roomId ? { ...room, unread: (room.unread ?? 0) + 1 } : room,
);
return 'pages' in prev
? { ...prev, pages: prev.pages.map((p) => ({ ...p, items: apply(p.items) })) }
: { ...prev, items: apply(prev.items) };
},
},
});
function RoomList() {
const { data, rooms } = socketedRooms.useEndpoint();
return (
<>
<p>Subscribed rooms: {rooms.join(', ')}</p>
<ul>{data?.items.map((r) => <li key={r.id}>{r.name}</li>)}</ul>
</>
);
}withSocketRoute(...) returns a built endpoint object with a socket-aware
useEndpoint() plus the original built helpers. It is the recommended
endpoint-first composition form. buildSocketedRoute({ built, ...options })
remains the equivalent lower-level object form.
toRoomsreceives{ data, args, meta }wheremeta.appContextcomes fromuseContext(if provided).applySockethandlers receive{ prev, payload, args, meta }, wheremetacontainsenvelope?,ctx?, andappContext?.- Return
nullfromapplySocketto skip cache updates.
applySocket functions are pure cache reducers, not subscriptions. They need
the wrapper because room membership, listeners, and cleanup follow the React
endpoint hook lifecycle. For application-global events that do not belong to a
route cache or room lifecycle, subscribe directly with SocketClient.on(...)
or the provider's useSocketEvent hook.
Built-in socket cache reducers
RRRoutes owns the reusable immutable cache mechanics for array and infinite
feed responses. Applications only decide item identity, query membership,
rooms, and how domain events map to the conventional socketDelete tombstone.
import { applyFeedUpsert } from '@emeryld/rrroutes-client/socket/cache'
import { withSocketRoute } from '@emeryld/rrroutes-client/socket/react'
const socketedMessages = withSocketRoute(messages, {
useSocketClient,
toRooms: ({ args }) => ({ rooms: [`chat:${args?.params.chatId}`] }),
applySocket: {
'message:upsert': ({ prev, payload, args }) =>
applyFeedUpsert(
{
prev,
payload: {
...payload,
socketDelete: payload.operation === 'delete',
},
args,
},
{
getId: (message) => message.id,
matchQuery: (message, routeArgs) =>
message.chatId === routeArgs?.params.chatId,
insertionEdge: 'previous',
},
),
},
})applyFeedUpsert updates existing items across pages, removes tombstones,
maintains numeric meta.total, and avoids inserting into incomplete anchored
windows. applyArrayUpsert provides the same update/delete behavior for plain
{ out: [] } responses. Both return null when the cache should retain its
current reference. Custom pagination metadata can provide isEdgeLoaded and
adjustTotal callbacks.
Edge cases & notes
- Mutation
fetchrequires a body argument; GETfetchonly requires a body when the leaf defines one. - Path and query params are validated with the contract schemas before fetch; missing params throw synchronously.
- Query objects that contain arrays/objects are JSON-stringified in the URL query string.
- Feed cache keys omit the cursor so
invalidate(['get','v1','posts'])clears all pages. setDataruns your updater against the current cache value; returnundefinedto leave the cache untouched.- When
environmentis'production', debug logs are disabled even ifdebugis set.
Full-stack guide
This package's chapters of the full-stack guide. Group 2 is the definition web and native share; group 3 starts the web app by supplying it with a transport.
Feeds are cursor-paginated, bidirectional, and can open anchored on a specific
item. createCursorPagination is the preset for exactly that shape; naming its
query fields to match the contract's queryExtensionSchema is what makes the
wiring disappear. Set on the route client, every feed: true leaf inherits it.
import { createCursorPagination } from '@emeryld/rrroutes-client/pagination'
/**
* Field names on both sides of the wire. The request keys match
* `FeedPagination` in the contract; the response keys match the `meta`
* `exposeRRRoutesResource` sends back by default.
*/
export const feedPagination = createCursorPagination({
query: { cursor: 'cursor', direction: 'direction', anchor: 'anchorItemId' },
meta: { nextCursor: 'nextCursor', previousCursor: 'previousCursor' },
})Note — Per-endpoint override A feed with a different cursor contract passes its own preset as the endpoint's
paginationbuild option, orfalseto opt out of the inherited one entirely.
Reference —
@emeryld/rrroutes-client/pagination
The adapter is the whole platform boundary. A route client to build endpoints with, a hook that returns the socket client, and anything else your composed endpoints need. Web and native each satisfy it their own way; the resource below never learns which one it got.
import type { ClientBindingAdapter } from '@emeryld/rrroutes-client/bindings'
import type { SocketClient } from '@emeryld/rrroutes-client/socket/react'
import type { PostEvents } from '../contract/posts.events'
export interface AppAdapter extends ClientBindingAdapter<Error> {
/** Reads the socket client out of the provider mounted by each app. */
useSocketClient: () => SocketClient<PostEvents>
/** Where the app is running. Used for cache sizing, not for behavior. */
platform: 'web' | 'native'
}Note — The optional
finalizeClientBindingAdapteralso accepts afinalize(endpoint)hook, run once per endpoint afterbuildand before anycompose. It is where a platform-wide concern — request logging, an offline queue — is attached without touching the resource definition.
Reference —
@emeryld/rrroutes-client/bindings
This is the centre of the client story. defaults sets TanStack options per
public endpoint name. compose wraps individual endpoints — here with
withSocketRoute, which binds room membership and cache reducers to the query
cache entry rather than to a component. A post screen and a timeline both
mounted on the same post share one subscription, and a post updated while
nothing is rendering still lands in the cache.
import { createClientBindings } from '@emeryld/rrroutes-client/bindings'
import { applyFeedUpsert } from '@emeryld/rrroutes-client/socket/cache'
import { withSocketRoute } from '@emeryld/rrroutes-client/socket/react'
import { postsResource } from '../contract/posts.resource'
import type { AppAdapter } from './adapter'
const defineClientResource = createClientBindings<Error, AppAdapter>()
export const createPostsClient = defineClientResource({
resource: postsResource,
name: 'posts',
defaults: {
getPost: { staleTime: 60_000, gcTime: 30 * 60_000 },
listPosts: {
gcTime: 5 * 60_000,
feed: { getItemId: (post) => post.id },
},
},
compose: {
/** One post: join its room, patch it in place when it changes. */
getPost: (endpoint, { adapter }) =>
withSocketRoute(endpoint, {
useSocketClient: adapter.useSocketClient,
toRooms: ({ data }) => ({
rooms: data?.out ? [`post:${data.out.id}`] : [],
}),
applySocket: {
'post:updated': ({ prev, payload }) =>
prev?.out.id === payload.id
? { ...prev, out: { ...prev.out, ...payload } }
: null,
},
}),
/** The feed: one room for the whole list, reducers for all three events. */
listPosts: (endpoint, { adapter }) =>
withSocketRoute(endpoint, {
useSocketClient: adapter.useSocketClient,
toRooms: () => ({ rooms: ['posts:all'] }),
applySocket: {
'post:created': (input) =>
applyFeedUpsert(input, {
getId: (post) => post.id,
// A new post belongs in this cache only if it matches the tag
// the feed is currently filtered by.
matchQuery: (post, args) => {
const tag = args?.[0]?.query?.tag
return !tag || post.tag === tag
},
insertionEdge: 'previous',
}),
'post:updated': (input) =>
applyFeedUpsert(input, {
getId: (post) => post.id,
matchQuery: () => true,
}),
'post:deleted': (input) =>
applyFeedUpsert(input, {
getId: (post) => post.id,
matchQuery: () => true,
}),
},
}),
},
})Note — Socket lifetime follows
gcTime, not mount Handlers register when the query is created and stay until it is removed from the cache. Unmounting the last observer only starts thegcTimetimer; a socket event arriving in that window updates the cache and does not extend the deadline. Remounting before collection reuses the existing registration — no duplicate joins.
Note — Reducers return a new reference or
nullnullandundefinedboth mean "no change". Mutatingprevand returning it is the one thing that will not work, because the wrapper compares references to decide whether to write.
Note — One owner per query Wrapping the same exact query with two socket routes throws rather than installing competing reducers. If two features need the same live data, they should share the endpoint — which is what putting it in this file achieves.
Note —
decoratewhen endpoints need each othercomposewraps endpoints independently. When the resource needs to return extra helpers, or an endpoint must reference a sibling, usedecorate(routes, adapter, resource)instead and return whatever shape the app should consume. The two forms are mutually exclusive.
Reference —
@emeryld/rrroutes-client/bindings,@emeryld/rrroutes-client/socket/cache,@emeryld/rrroutes-client/socket/react
buildSocketProvider closes over the event map and every option except the
socket instance itself, and returns a provider plus hooks typed to your events.
Sharing the factory is what makes the heartbeat, the system events and the
debug configuration identical on web and native; each app supplies only the
connection, in step 3.1 and step 4.1.
import { buildSocketProvider } from '@emeryld/rrroutes-client/socket/react'
import { z } from 'zod'
import { events } from '../contract/posts.events'
export const { SocketProvider, useSocketClient, useSocketConnection } =
buildSocketProvider<typeof events>({
events,
options: {
environment:
process.env.NODE_ENV === 'production' ? 'production' : 'development',
heartbeat: {
intervalMs: 15_000,
timeoutMs: 7_500,
onPong: ({ latencyMs }) => metrics.gauge('socket.latency', latencyMs),
},
sys: {
'sys:connect': {
sysHandler: ({ startHeartbeat, next }) => {
startHeartbeat()
next()
},
},
'sys:disconnect': {
sysHandler: ({ stopHeartbeat, next }) => {
stopHeartbeat()
next()
},
},
'sys:ping': { message: z.object({ sentAt: z.string() }) },
'sys:pong': {
message: z.object({ sentAt: z.string(), sinceMs: z.number() }),
},
'sys:room_join': { message: z.object({ rooms: z.array(z.string()) }) },
'sys:room_leave': { message: z.object({ rooms: z.array(z.string()) }) },
},
},
})Note — Rooms are reference counted Three components joining
posts:allemit onesys:room_join; the third to leave emits onesys:room_leave. This is whywithSocketRouteand a hand-writtenuseSocketConnectioncan both ask for the same room without fighting.
Note — A null socket is safe
emit,on,joinRoomsandleaveRoomsare all no-ops while the underlying socket is null, warning in development and silent in production. Code does not need to guard the bootstrap window.
Reference —
@emeryld/rrroutes-client/socket/react
Three providers, in one place. TanStack Query owns the cache, the shared
SocketProvider owns the connection, and RRRoutesUIProvider owns the
defaults every boundary inherits — the error grace period, the skeleton count,
and the event hook a logger listens on.
import { createRouteClient } from '@emeryld/rrroutes-client/react-query'
import { RRRoutesUIProvider } from '@emeryld/rrroutes-ui'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { io } from 'socket.io-client'
import { useState, type PropsWithChildren } from 'react'
import { feedPagination } from '@app/shared/client/pagination'
import { SocketProvider } from '@app/shared/client/socket'
export const queryClient = new QueryClient()
export const routeClient = createRouteClient({
baseUrl: '/api',
queryClient,
pagination: feedPagination,
validateResponses: true,
environment: import.meta.env.PROD ? 'production' : 'development',
debug: { logger: console.debug },
})
export function Providers({ children }: PropsWithChildren) {
const [socket] = useState(() =>
io(import.meta.env.VITE_SOCKET_URL, {
transports: ['websocket'],
withCredentials: true,
}),
)
return (
<QueryClientProvider client={queryClient}>
<SocketProvider socket={socket} fallback={<AppSplash />}>
<RRRoutesUIProvider
errorGraceMs={4_000}
skeletonCount={4}
slots={{
loader: <Spinner />,
error: ({ refetch }) => <Retry onPress={refetch} />,
}}
onEvent={(event) => logger.info(event.type, event)}
>
{children}
</RRRoutesUIProvider>
</SocketProvider>
</QueryClientProvider>
)
}Note — Why responses are validated
validateResponsesparses every response against its leaf's output schema before it reaches the cache. It costs a parse per request and turns a backend that quietly changed shape into an error at the boundary that received it, rather than a crash three components deep.
Reference —
@emeryld/rrroutes-client/react-query,@emeryld/rrroutes-ui
Four lines. The adapter is satisfied with the web transport, and
createPostsClient returns the endpoints — already socket-bound, already
carrying their cache defaults, because all of that was decided in step 2.3.
import { createPostsClient } from '@app/shared/client/posts.client'
import { useSocketClient } from '@app/shared/client/socket'
import { routeClient } from './providers'
export const posts = createPostsClient({
routeClient,
useSocketClient,
platform: 'web',
})
// posts.getPost — live single post
// posts.listPosts — live infinite feed
// posts.createPost — mutation
// posts.updatePost — mutation
// posts.deletePost — mutationReference —
@emeryld/rrroutes-client/bindings
Scripts (monorepo)
Run from the repo root:
pnpm --filter @emeryld/rrroutes-client build
pnpm --filter @emeryld/rrroutes-client typecheck
pnpm --filter @emeryld/rrroutes-client test