@richie-rpc/react-query
v2.0.0
Published
A TypeScript-first, type-safe API contract library for Bun with Zod validation
Maintainers
Readme
@richie-rpc/react-query
React hooks integration for Richie RPC using TanStack Query (React Query v5). Provides type-safe hooks with automatic caching, background refetching, React Suspense support, and streaming integration.
Installation
bun add @richie-rpc/react-query @richie-rpc/client @richie-rpc/core @tanstack/react-query zod@^4Features
- 🎯 Fully Type-Safe: Complete TypeScript inference from contract to hooks
- 🔄 Automatic Method Detection: GET/HEAD → queries, POST/PUT/PATCH/DELETE → mutations
- ⚡ React Suspense: Built-in support with
useSuspenseQuery - 💾 Smart Caching: Powered by TanStack Query
- 🎨 Unified Options: ts-rest-style
queryKey/queryDatapattern - 📖 Infinite Queries: Built-in pagination support
- 🌊 Streaming Integration: TanStack Query integration via
useStreamQuery - 🔧 Typed QueryClient: Per-endpoint cache operations via
createTypedQueryClient
Quick Start
1. Create API
import { createTanstackQueryApi } from '@richie-rpc/react-query';
import { client, contract } from './api'; // your client setup
const api = createTanstackQueryApi(client, contract);2. Use Query Hooks (GET requests)
Query hooks use the unified queryKey/queryData pattern:
function UserList() {
const { data, isLoading, error, refetch } = api.listUsers.useQuery({
queryKey: ['users', { limit: '10', offset: '0' }],
queryData: { query: { limit: '10', offset: '0' } },
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
{data.payload.users.map((user) => (
<div key={user.id}>{user.name}</div>
))}
<button onClick={() => refetch()}>Refresh</button>
</div>
);
}3. Use Suspense Queries
For React Suspense integration:
function UserListSuspense() {
const { data } = api.listUsers.useSuspenseQuery({
queryKey: ['users'],
queryData: { query: { limit: '10' } },
});
return (
<div>
{data.payload.users.map((user) => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
}
// Wrap with Suspense boundary
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<UserListSuspense />
</Suspense>
);
}4. Use Mutation Hooks (POST/PUT/PATCH/DELETE)
Mutation hooks return a function to trigger the request:
function CreateUserForm() {
const mutation = api.createUser.useMutation({
onSuccess: (data) => {
console.log('User created:', data);
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
mutation.mutate({ body: { name: 'Alice', email: '[email protected]' } });
}}
>
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create User'}
</button>
</form>
);
}API Reference
createTanstackQueryApi(client, contract)
Creates a typed API object from a client and contract.
Parameters:
client: Client created withcreateClient()contract: Your API contract definition
Returns: API object with per-endpoint hooks and methods
Query Endpoint API (GET/HEAD)
api.endpoint.useQuery(options)
Standard query hook.
const { data, isLoading, error } = api.listUsers.useQuery({
queryKey: ['users'],
queryData: { query: { limit: '10' } },
staleTime: 5000,
// ...other TanStack Query options
});api.endpoint.useSuspenseQuery(options)
Suspense-enabled query hook.
const { data } = api.listUsers.useSuspenseQuery({
queryKey: ['users'],
queryData: { query: { limit: '10' } },
});api.endpoint.useInfiniteQuery(options)
Infinite query for pagination.
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = api.listUsers.useInfiniteQuery({
queryKey: ['users'],
queryData: ({ pageParam }) => ({
query: { limit: '10', offset: String(pageParam) },
}),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
const nextOffset = allPages.length * 10;
return lastPage.payload.users.length === 10 ? nextOffset : undefined;
},
});api.endpoint.useSuspenseInfiniteQuery(options)
Suspense-enabled infinite query.
api.endpoint.query(options)
Direct fetch without React Query.
const result = await api.listUsers.query({ query: { limit: '10' } });Mutation Endpoint API (POST/PUT/PATCH/DELETE)
api.endpoint.useMutation(options?)
Mutation hook.
const mutation = api.createUser.useMutation({
onSuccess: (data) => console.log('Created:', data),
onError: (error) => console.error('Failed:', error),
});
mutation.mutate({ body: { name: 'Alice', email: '[email protected]' } });api.endpoint.mutate(options)
Direct mutate without React Query.
const result = await api.createUser.mutate({
body: { name: 'Alice', email: '[email protected]' },
});Streaming Endpoint API
api.endpoint.stream(options)
Direct stream access with event-based API:
const result = await api.streamChat.stream({ body: { prompt: 'Hello' } });
result.on('chunk', (chunk) => {
console.log(chunk.text);
});
result.on('close', (finalResponse) => {
console.log('Done:', finalResponse);
});api.endpoint.useStreamQuery(options)
TanStack Query integration using experimental_streamedQuery:
const { data: chunks, isFetching } = api.streamChat.useStreamQuery({
queryKey: ['chat', prompt],
queryData: { body: { prompt } },
refetchMode: 'reset', // 'reset' | 'append' | 'replace'
});
// chunks = accumulated array of chunk objects
// isFetching = true while streamingSSE Endpoint API
api.endpoint.connect(options)
Direct SSE connection:
const connection = api.notifications.connect({ params: { id: '123' } });
connection.on('message', (data) => {
console.log('Message:', data.text);
});
connection.on('heartbeat', (data) => {
console.log('Heartbeat:', data.timestamp);
});Download Endpoint API
api.endpoint.download(options)
Direct file download:
const response = await api.downloadFile.download({ params: { id: 'file123' } });createTypedQueryClient(queryClient, client, contract)
Create a typed QueryClient wrapper with per-endpoint cache methods. This is useful for type-safe cache operations like prefetching, getting/setting query data, etc.
import { createTypedQueryClient } from '@richie-rpc/react-query';
// Create at module level alongside your api
const typedClient = createTypedQueryClient(queryClient, client, contract);
// Type-safe cache operations
typedClient.listUsers.getQueryData(['users']);
typedClient.listUsers.setQueryData(['users'], (old) => ({
...old,
payload: { ...old.payload, users: [...old.payload.users, newUser] },
}));
// Prefetching
await typedClient.listUsers.prefetchQuery({
queryKey: ['users'],
queryData: { query: { limit: '10' } },
});Available methods per query endpoint:
getQueryData(queryKey)- Get cached datasetQueryData(queryKey, updater)- Update cached datagetQueryState(queryKey)- Get query statefetchQuery(options)- Fetch and cache dataprefetchQuery(options)- Prefetch data in backgroundensureQueryData(options)- Get cached data or fetch if missing
Error Handling
When an endpoint defines errorResponses in the contract, error status codes are thrown as ErrorResponse by the client. In TanStack Query, these land on the error field (for useQuery) or are caught by error boundaries (for useSuspenseQuery).
Per-Endpoint isErrorResponse() Type Guard
Each endpoint exposes an isErrorResponse() method that narrows the error to a TypedErrorResponse with fully typed status and payload:
function UserProfile({ userId }: { userId: string }) {
const { data, error, isLoading } = api.getUser.useQuery({
queryKey: ['user', userId],
queryData: { params: { id: userId } },
});
if (error) {
if (api.getUser.isErrorResponse(error)) {
// error.status and error.payload are fully typed from the contract's errorResponses
return <div>Error {error.status}: {error.payload.error}</div>;
}
return <div>Network error: {error.message}</div>;
}
if (isLoading || !data) return <div>Loading...</div>;
// data.payload is always the success type — no status discrimination needed
return <div>{data.payload.name}</div>;
}Suspense + Error Boundary Pattern
With useSuspenseQuery, error responses are thrown and caught by error boundaries, so data is always the success type:
import { ErrorResponse } from '@richie-rpc/client';
class ApiErrorBoundary extends React.Component<
{ children: React.ReactNode; fallback: (error: Error) => React.ReactNode },
{ error: Error | null }
> {
override state = { error: null as Error | null };
static getDerivedStateFromError(error: Error) { return { error }; }
override render() {
if (this.state.error) return this.props.fallback(this.state.error);
return this.props.children;
}
}
function UserDetail({ userId }: { userId: string }) {
const { data } = api.getUser.useSuspenseQuery({
queryKey: ['user', userId],
queryData: { params: { id: userId } },
});
// No status check needed! data.payload is always the success type
return <h3>{data.payload.name}</h3>;
}
// Usage
<ApiErrorBoundary fallback={(error) => {
if (error instanceof ErrorResponse && error.status === 404) {
return <div>User not found</div>;
}
return <div>Something went wrong</div>;
}}>
<Suspense fallback={<div>Loading...</div>}>
<UserDetail userId="123" />
</Suspense>
</ApiErrorBoundary>HookError<T>
The error type for hooks is HookError<T>. When the endpoint has errorResponses, it is ErrorResponse | Error. Otherwise, it is just Error.
Standalone isErrorResponse(error)
A standalone type guard is also available:
import { isErrorResponse } from '@richie-rpc/react-query';
if (isErrorResponse(error)) {
console.log(error.status, error.payload);
}Other Utilities
isFetchError(error)— Returnstrueif the error is a network/fetch errorisUnknownErrorResponse(error, endpoint)— Returnstrueif the error has a status code not defined in the contractisNotKnownResponseError(error, endpoint)— Returnstrueif the error is either a fetch error or an unknown response errorexhaustiveGuard(value)— For compile-time exhaustiveness checking in switch statements
Advanced Usage
Custom Query Options
Pass TanStack Query options alongside queryKey and queryData:
const { data } = api.listUsers.useQuery({
queryKey: ['users'],
queryData: { query: { limit: '10' } },
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes (formerly cacheTime)
refetchInterval: 30000, // Refetch every 30 seconds
refetchOnWindowFocus: false,
});Dependent Queries
Enable queries only when conditions are met:
function UserPosts({ userId }: { userId: string | null }) {
const { data } = api.getUserPosts.useQuery({
queryKey: ['posts', userId],
queryData: { params: { userId: userId! } },
enabled: !!userId, // Only fetch when userId is available
});
}Optimistic Updates
Update the UI immediately before the server responds:
// Module level - create once
const typedClient = createTypedQueryClient(queryClient, client, contract);
function UpdateUserForm({ userId }: { userId: string }) {
const mutation = api.updateUser.useMutation({
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: ['user', userId] });
const previousUser = typedClient.getUser.getQueryData(['user', userId]);
typedClient.getUser.setQueryData(['user', userId], (old) => ({
...old,
payload: { ...old.payload, ...variables.body },
}));
return { previousUser };
},
onError: (err, variables, context) => {
if (context?.previousUser) {
typedClient.getUser.setQueryData(['user', userId], context.previousUser);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['user', userId] });
},
});
}TypeScript Types
Exported Types
import type {
TsrQueryOptions,
TsrSuspenseQueryOptions,
TsrInfiniteQueryOptions,
TsrSuspenseInfiniteQueryOptions,
TsrMutationOptions,
TsrStreamQueryOptions,
TsrResponse,
TsrError,
HookError,
TypedQueryClient,
TanstackQueryApi,
} from '@richie-rpc/react-query';Type Inference
Extract types from your API:
import type { EndpointResponse } from '@richie-rpc/client';
// Get the response type for an endpoint
type UserListResponse = EndpointResponse<typeof contract.listUsers>;TanStack Query Re-exports
For version consistency, you can import TanStack Query from this package:
import { QueryClient, QueryClientProvider } from '@richie-rpc/react-query/tanstack';Best Practices
- Create API once: Create the API object at the module level, not inside components
- Use meaningful queryKeys: Include relevant parameters in queryKey for proper cache separation
- Use Suspense for loading states: Cleaner than manual loading state management
- Invalidate related queries: After mutations, invalidate queries that may be affected
- Use createTypedQueryClient: For type-safe cache operations like prefetching and setQueryData
- Handle errors exhaustively: Use the error utilities for proper error handling
Examples
See the packages/demo directory for complete working examples:
- react-example.tsx - Query and mutation hooks
- dictionary-example.tsx - Complex data structures
License
MIT
