@emeryld/rrroutes-client
v2.4.1
Published
<!-- Summary: - Added comprehensive usage for `createRouteClient`, built endpoints (GET/feeds/mutations), cache helpers, invalidation, debug modes, custom fetchers, and React Query integration. - Added new sections for router helpers, infinite/feeds, Form
Downloads
8,202
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-client @tanstack/react-query
# or
npm install @emeryld/rrroutes-client @tanstack/react-query@emeryld/rrroutes-contract and zod come along as dependencies; you supply React Query.
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.build(leaf, options?, meta?)returns a helper that exposes:useEndpoint(args?)— React hook for GET/feeds/mutations (typed params/query/body/output).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.
Detailed usage
1) Configure the client
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
},
})2) Build 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'], {
cursorParam: 'page', // defaults to "pagination_cursor"
getNextPageParam: (last) => last.nextCursor, // React Query option override
})
// Mutation
const updateUser = client.build(registry.byKey['PATCH /v1/users/:userId'], {
onSuccess: () => client.invalidate(['get', 'v1', 'users']), // prefix invalidate
})3) 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 bodySchema4) 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.
5) 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>
);
}6) 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).
7) 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 filtering8) File uploads (FormData)
If a leaf has bodyFiles set in its contract, the client automatically converts the body to FormData:
const uploadAvatar = client.build(
registry.byKey['PUT /v1/users/:userId/avatar'],
)
await uploadAvatar.fetch(
{ params: { userId: 'u_1' } },
{ avatar: new File([blob], 'avatar.png', { type: 'image/png' }) },
)9) 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' },
)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, { ctx }) => {
console.log('socket message', payload.text, 'latency', 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: buildSocketedRoute
Automatically join rooms based on fetched data and patch the cache when socket messages arrive.
import { buildSocketedRoute } from '@emeryld/rrroutes-client';
import { useSocketClient } from './socketProvider';
const listRooms = client.build(registry.byKey['GET /v1/rooms'], { staleTime: 120_000 });
const useSocketedRooms = buildSocketedRoute({
built: listRooms,
toRooms: (page) => ({
rooms: page.items.map((r) => r.id), // derive rooms from data (feeds supported)
joinMeta: { source: 'rooms:list' },
leaveMeta: { source: 'rooms:list' },
}),
useSocketClient,
applySocket: {
'chat:message': (prev, payload) => {
if (!prev) return prev;
// 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 } = useSocketedRooms();
return (
<>
<p>Subscribed rooms: {rooms.join(', ')}</p>
<ul>{data?.items.map((r) => <li key={r.id}>{r.name}</li>)}</ul>
</>
);
}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.
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