kitcn
Version:
kitcn - React Query integration and CLI tools for Convex
673 lines (521 loc) • 19.8 kB
Markdown
# React & RSC Reference
> Prerequisites: `setup/react.md`, `setup/next.md`
Covers all kitcn React client, TanStack Query integration, and Next.js RSC patterns. Assumes TanStack Query baseline knowledge.
## Setup
### createCRPCContext
```ts
// src/lib/convex/crpc.tsx
import { api } from '@convex/api';
import { createCRPCContext } from 'kitcn/react';
export const { CRPCProvider, useCRPC, useCRPCClient } = createCRPCContext({
api,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
transformer, // optional — Date always enabled ($date wire tag). Use createTaggedTransformer for extra codecs.
});
```
| Export | Description |
|--------|-------------|
| `CRPCProvider` | Context provider — wraps children with cRPC proxy |
| `useCRPC` | Hook → cRPC proxy for `queryOptions`/`mutationOptions`/`infiniteQueryOptions` |
| `useCRPCClient` | Hook → typed vanilla client for imperative `client.path.query()`/`mutate()` |
### QueryClient
cRPC auto-sets `staleTime: Infinity`, `refetch*: false` per query (Convex pushes via WebSocket — never stale).
```ts
// src/lib/convex/query-client.ts
import { defaultShouldDehydrateQuery, QueryCache, QueryClient } from '@tanstack/react-query';
import { isCRPCClientError, isCRPCError } from 'kitcn/crpc';
import SuperJSON from 'superjson';
// Shared hydration config for SSR (client + server)
export const hydrationConfig = {
dehydrate: {
serializeData: SuperJSON.serialize,
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
shouldRedactErrors: () => false,
},
hydrate: { deserializeData: SuperJSON.deserialize },
};
export function createQueryClient() {
return new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
if (isCRPCClientError(error)) {
console.log(`[CRPC] ${error.code}:`, error.functionName);
}
},
}),
defaultOptions: {
...hydrationConfig,
mutations: {
onError: (err) => {
const error = err as Error & { data?: { message?: string } };
toast.error(error.data?.message || error.message);
},
},
queries: {
retry: (failureCount, error) => {
if (isCRPCError(error)) return false; // don't retry deterministic errors
return failureCount < 3;
},
retryDelay: (i) => Math.min(2000 * 2 ** i, 30_000),
},
},
});
}
```
### Provider Hierarchy
**Without auth:**
```tsx
// src/lib/convex/convex-provider.tsx
'use client';
import { QueryClientProvider } from '@tanstack/react-query';
import { ConvexProvider, ConvexReactClient, getQueryClientSingleton, getConvexQueryClientSingleton } from 'kitcn/react';
import { CRPCProvider } from '@/lib/convex/crpc';
import { createQueryClient } from '@/lib/convex/query-client';
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function AppConvexProvider({ children }) {
return (
<ConvexProvider client={convex}>
<QueryProvider>{children}</QueryProvider>
</ConvexProvider>
);
}
function QueryProvider({ children }) {
const queryClient = getQueryClientSingleton(createQueryClient);
const convexQueryClient = getConvexQueryClientSingleton({ convex, queryClient });
return (
<QueryClientProvider client={queryClient}>
<CRPCProvider convexClient={convex} convexQueryClient={convexQueryClient}>
{children}
</CRPCProvider>
</QueryClientProvider>
);
}
```
**With auth** — swap `ConvexProvider` for `ConvexAuthProvider`:
```tsx
import { ConvexAuthProvider } from 'kitcn/auth/client';
import { ConvexReactClient, getConvexQueryClientSingleton, getQueryClientSingleton, useAuthStore } from 'kitcn/react';
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function AppConvexProvider({ children, token }: { children: ReactNode; token?: string }) {
const router = useRouter();
return (
<ConvexAuthProvider
authClient={authClient}
client={convex}
initialToken={token}
onMutationUnauthorized={() => router.push('/login')}
onQueryUnauthorized={() => router.push('/login')}
>
<QueryProvider>{children}</QueryProvider>
</ConvexAuthProvider>
);
}
function QueryProvider({ children }) {
const authStore = useAuthStore(); // pass to singleton
const queryClient = getQueryClientSingleton(createQueryClient);
const convexQueryClient = getConvexQueryClientSingleton({ authStore, convex, queryClient });
return (
<QueryClientProvider client={queryClient}>
<CRPCProvider convexClient={convex} convexQueryClient={convexQueryClient}>
{children}
</CRPCProvider>
</QueryClientProvider>
);
}
```
### Singleton Helpers
| Helper | Behavior |
|--------|----------|
| `getQueryClientSingleton(factory)` | Same instance on client, fresh per SSR request |
| `getConvexQueryClientSingleton(opts)` | Creates/connects ConvexQueryClient bridge |
`getConvexQueryClientSingleton` options:
- `convex` — ConvexReactClient
- `queryClient` — TanStack QueryClient
- `authStore` — from `useAuthStore()` (auth apps only)
- `unsubscribeDelay` — ms before unsubscribing after unmount (default 3000). Covers StrictMode + quick back-nav.
### ConvexQueryClient (Bridge)
Bridges WebSocket subscriptions → TanStack Query cache. Push model (not pull):
```
useQuery() → WebSocket subscription → real-time updates → cache always fresh
```
Defaults: `staleTime: Infinity`, `gcTime: 5min`, `refetchOnMount: false`, `refetchOnWindowFocus: false`.
Lifecycle: Mount → subscribe → unmount → wait `unsubscribeDelay` → unsubscribe (cache persists for `gcTime`).
---
## Queries
### queryOptions
```ts
const crpc = useCRPC();
const { data } = useQuery(crpc.user.list.queryOptions({}));
const { data } = useQuery(crpc.user.get.queryOptions({ id }));
const { data } = useQuery(crpc.user.get.queryOptions({ id }, { enabled: !!id, placeholderData: null }));
```
Signature: `crpc.path.queryOptions(args, options?)`
cRPC-specific options beyond standard TanStack Query:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `skipUnauth` | `boolean` | `false` | Skip query when not authenticated (returns undefined) |
| `subscribe` | `boolean` | `true` | Enable real-time WebSocket subscription |
**With `select`** — spread options, add select separately:
```ts
const { data } = useSuspenseQuery({
...crpc.http.health.queryOptions(),
select: (data) => data.status, // data: string
});
```
### Real-time Subscriptions
Default ON. Every `queryOptions` subscribes to Convex WebSocket. Disable with `subscribe: false`:
```ts
useQuery(crpc.analytics.getReport.queryOptions({ period }, { subscribe: false }));
// refresh manually:
queryClient.invalidateQueries(crpc.analytics.getReport.queryFilter());
```
### Auth-Aware Queries
**`skipUnauth`** — client-side: returns undefined when not authenticated:
```ts
useQuery(crpc.user.getCurrentUser.queryOptions({}, { skipUnauth: true }));
```
**`meta({ auth })` on procedures** — controls query behavior during auth loading:
| Procedure type | Auth loading | Logged out |
|----------------|-------------|------------|
| `publicQuery` | Runs immediately | Runs |
| `optionalAuthQuery` (auth: 'optional') | **Waits** | Runs |
| `authQuery` (auth: 'required') | **Waits** | **Skips** |
The procedure builders (`authQuery`, `publicQuery`, etc.) already include correct `.meta()` settings.
### Conditional Queries
```ts
// enabled
useQuery(crpc.user.getSettings.queryOptions({ userId: user?.id }, { enabled: !!user }));
// skipToken
import { skipToken } from '@tanstack/react-query';
useQuery(crpc.user.get.queryOptions(userId ? { id: userId } : skipToken));
```
### Query Keys & Filters
```ts
const queryKey = crpc.user.list.queryKey({}); // ['convexQuery', 'user:list', {}]
const data = queryClient.getQueryData(queryKey);
const filter = crpc.user.list.queryFilter({}, { predicate: (q) => q.state.dataUpdatedAt > Date.now() - 60000 });
queryClient.invalidateQueries(filter);
```
### Imperative Calls
Three methods:
| Method | Context | Caching | Use Case |
|--------|---------|---------|----------|
| `client.*.query()` | Anywhere | None | Direct calls, no cache |
| `crpc.*.queryOptions()` | Render only | Cache | Components (uses hooks) |
| `crpc.*.staticQueryOptions()` | Anywhere | Cache | Prefetch, event handlers |
```ts
// useCRPCClient — direct calls
const client = useCRPCClient();
const user = await client.user.get.query({ id });
await client.user.update.mutate({ id, name: 'test' });
// staticQueryOptions — prefetch in event handlers (no hooks, no reactive auth)
const handleMouseEnter = () => {
queryClient.prefetchQuery(crpc.user.get.staticQueryOptions({ id }));
};
```
### Actions as Queries
Actions (external API calls) auto-detected, no subscription:
```ts
const { data } = useQuery(crpc.ai.analyze.queryOptions({ documentId }));
```
---
## Mutations
### mutationOptions
```ts
const crpc = useCRPC();
const mutation = useMutation(crpc.user.create.mutationOptions());
const mutation = useMutation(crpc.user.update.mutationOptions({
onSuccess: (data) => toast.success('Updated'),
onError: (error) => toast.error(error.data?.message ?? 'Failed'),
}));
```
Signature: `crpc.path.mutationOptions(options?)` — standard TanStack mutation options except `mutationFn`.
### Mutation Keys
```ts
const key = crpc.user.create.mutationKey(); // ['convexMutation', 'user:create']
```
### Common Patterns
**Toast promise:**
```ts
toast.promise(mutation.mutateAsync({ title }), {
loading: 'Creating...', success: 'Created!',
error: (e) => e.data?.message ?? 'Failed',
});
```
**Form with cleanup:**
```ts
const mutation = useMutation(crpc.user.update.mutationOptions({
onSuccess: () => { form.reset(); closeModal(); toast.success('Updated'); },
}));
```
**Inline callbacks:**
```ts
mutation.mutate({ id }, {
onSuccess: () => router.push('/sessions'),
onError: () => toast.error('Delete failed'),
});
```
### Actions as Mutations
Actions work with `mutationOptions` for external API calls (no real-time):
```ts
const scrape = useMutation(crpc.scraper.scrapeLink.mutationOptions());
```
---
## Infinite Queries
Import `useInfiniteQuery` from `kitcn/react` (wraps TanStack with Convex subscription logic):
```ts
import { useInfiniteQuery } from 'kitcn/react';
const crpc = useCRPC();
const { data, fetchNextPage, hasNextPage, isLoading, status } = useInfiniteQuery(
crpc.session.list.infiniteQueryOptions({ userId })
);
// data is flattened T[] — all loaded items
// status: 'LoadingFirstPage' | 'LoadingMore' | 'CanLoadMore' | 'Exhausted'
```
### infiniteQueryOptions
```ts
crpc.path.infiniteQueryOptions(args, options?)
```
| Option | Type | Description |
|--------|------|-------------|
| `limit` | `number` | Items per page (optional if `.paginated(limit)` on server, must be ≤ server limit) |
| `skipUnauth` | `boolean` | Skip when unauthenticated |
Access server limit: `crpc.session.list.meta.limit`
### Backend Setup
```ts
// convex/functions/session.ts
export const list = publicQuery
.input(z.object({ userId: z.string().optional() }))
.paginated({ limit: 20, item: SessionSchema })
.query(async ({ ctx, input }) => {
// input.cursor and input.limit auto-added
return ctx.orm.query.session.findMany({
where: input.userId ? { userId: input.userId } : undefined,
orderBy: { createdAt: 'desc' },
cursor: input.cursor,
limit: input.limit,
});
// output auto-wrapped as { continueCursor, isDone, page }
});
```
`.paginated({ limit, item })`:
- Adds `cursor` (string|null) and `limit` (number) to input
- Auto-sets output schema: `{ continueCursor: string, isDone: boolean, page: T[] }`
- Must be called before `.query()`
### Return Value
See [Infinite Query Return Value](#infinite-query-return-value) in the API Reference below.
### Prefetching
```ts
await queryClient.prefetchQuery(crpc.session.list.infiniteQueryOptions({ userId }));
```
### Placeholder Data
```ts
const { data, isPlaceholderData } = useInfiniteQuery(
crpc.session.list.infiniteQueryOptions({}, {
placeholderData: Array.from({ length: crpc.session.list.meta.limit }).map((_, i) => ({
id: i.toString() as Id<'session'>, token: 'Loading...', expiresAt: 0,
})),
})
);
```
### Real-time & Error Recovery
Each page maintains its own WebSocket subscription. Auto-recovers on `InvalidCursor` (resets to page 0) and `splitCursor` (auto-splits page). Pagination state persists in `queryClient` for scroll restoration.
---
## Error Handling
### Server Errors
`CRPCError` thrown server-side → arrives as `ConvexError` on client. Access
the built-in fields and any custom payload via `error.data`:
```ts
// Server:
throw new CRPCError({
code: 'CONFLICT',
message: 'Domain already exists',
data: { existingSiteId: 'site_123' },
});
// Client:
error.data?.message; // 'Domain already exists'
error.data?.existingSiteId; // 'site_123'
const { error, isError } = useQuery(crpc.posts.get.queryOptions({ id }));
if (isError) toast.error(error.data?.message ?? 'Something went wrong');
// Mutation callback:
crpc.posts.create.mutationOptions({ onError: (error) => toast.error(error.data?.message ?? 'Failed') });
// Try/catch:
const error = err as Error & {
data?: {
message?: string;
existingSiteId?: string;
};
};
```
### Client Errors
`CRPCClientError` — thrown client-side when queries are skipped (auth):
```ts
import { CRPCClientError, isCRPCClientError, isCRPCErrorCode } from 'kitcn/crpc';
if (isCRPCClientError(error)) {
error.code; // 'UNAUTHORIZED'
error.functionName; // 'user:getSettings'
}
if (isCRPCErrorCode(error, 'UNAUTHORIZED')) router.push('/login');
```
| Code | Description |
|------|-------------|
| `UNAUTHORIZED` | Missing authentication |
| `FORBIDDEN` | Not authorized |
| `NOT_FOUND` | Resource not found |
| `BAD_REQUEST` | Invalid input |
| `TOO_MANY_REQUESTS` | Rate limited |
### Global Error Handling
```ts
new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
if (isCRPCClientError(error)) console.log(`[CRPC] ${error.code}:`, error.functionName);
},
}),
});
```
---
## Type Inference
```ts
import type { Api, ApiInputs, ApiOutputs } from '@convex/api';
```
Bracket notation:
```ts
type User = ApiOutputs['user']['get'];
type GetUserArgs = ApiInputs['user']['get'];
type OrgMember = ApiOutputs['organization']['members']['list'][number]; // array item
```
---
## Next.js Setup
### Caller Factory
```ts
// src/lib/convex/server.ts
import { api } from '@convex/api';
import { convexBetterAuth } from 'kitcn/auth/nextjs';
export const { createContext, createCaller, handler } = convexBetterAuth({
api,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
});
```
| Export | Description |
|--------|-------------|
| `createContext` | RSC context with auth |
| `createCaller` | Server-side caller factory |
| `handler` | Next.js API route handler (`export const { GET, POST } = handler;`) |
Options: `api`, `convexSiteUrl`, `auth.jwtCache` (default true), `auth.isUnauthorized`.
### Client Provider with Auth
```tsx
// layout.tsx
const token = await caller.getToken();
return <ConvexProvider token={token}>{children}</ConvexProvider>;
```
### API Route
```ts
// src/app/api/auth/[...all]/route.ts
import { handler } from '@/lib/convex/server';
export const { GET, POST } = handler;
```
---
## RSC Patterns
### RSC Setup
```tsx
// src/lib/convex/rsc.tsx
import 'server-only';
import { createServerCRPCProxy, getServerQueryClientOptions } from 'kitcn/rsc';
import { cache } from 'react';
import { headers } from 'next/headers';
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { hydrationConfig } from './query-client';
import { createCaller, createContext } from './server';
const createRSCContext = cache(async () => createContext({ headers: await headers() }));
// Direct server calls (not cached/hydrated)
export const caller = createCaller(createRSCContext);
// Server cRPC proxy (queryOptions only, no mutations)
export const crpc = createServerCRPCProxy({ api });
// Server QueryClient with HTTP-based fetching
const createServerQueryClient = () => new QueryClient({
defaultOptions: {
...hydrationConfig,
...getServerQueryClientOptions({
getToken: caller.getToken,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
}),
},
});
export const getQueryClient = cache(createServerQueryClient);
// Fire-and-forget prefetch
export function prefetch<T extends { queryKey: readonly unknown[] }>(opts: T): void {
void getQueryClient().prefetchQuery(opts);
}
// Hydration wrapper
export function HydrateClient({ children }: { children: React.ReactNode }) {
return (
<HydrationBoundary state={dehydrate(getQueryClient())}>
{children}
</HydrationBoundary>
);
}
// Awaited fetch + hydration (equivalent to Convex preloadQuery)
export function preloadQuery<T>(options: FetchQueryOptions<T>): Promise<T> {
return getQueryClient().fetchQuery(options);
}
```
### Three RSC Patterns
| Pattern | Blocking | Returns data | Client hydration | Use case |
|---------|----------|-------------|------------------|----------|
| `prefetch` | No | No (void) | Yes | Client-only data, non-blocking |
| `caller` | Yes | Yes | **No** | Server-only logic (redirects, auth checks, sensitive data) |
| `preloadQuery` | Yes | Yes | Yes | Server + client data (metadata, 404 checks) |
**prefetch** (preferred — non-blocking, client owns data):
```tsx
export default async function PostsPage() {
prefetch(crpc.posts.list.queryOptions({}));
return <HydrateClient><PostList /></HydrateClient>;
}
```
**caller** (server-only, not hydrated):
```tsx
const user = await caller.user.getSessionUser({});
if (!user?.isAdmin) redirect('/');
```
**preloadQuery** (awaited + hydrated — use sparingly):
```tsx
const post = await preloadQuery(crpc.posts.get.queryOptions({ id }));
if (!post) notFound();
return <HydrateClient><h1>{post.title}</h1><PostContent /></HydrateClient>;
```
### Auth-Aware Prefetching
```tsx
prefetch(crpc.user.getCurrentUser.queryOptions({}, { skipUnauth: true }));
```
### Multiple Prefetches
```tsx
prefetch(crpc.user.getCurrentUser.queryOptions({}, { skipUnauth: true }));
prefetch(crpc.posts.list.queryOptions({}));
prefetch(crpc.stats.dashboard.queryOptions({}));
return <HydrateClient><Dashboard /></HydrateClient>;
```
### Metadata Generation
```tsx
export async function generateMetadata({ params }) {
const { id } = await params;
const post = await preloadQuery(crpc.posts.get.queryOptions({ id }));
return { title: post?.title ?? 'Not Found', description: post?.excerpt };
}
```
### HydrateClient Placement
Must wrap ALL client components that use prefetched queries. Server and client proxies generate identical query keys (`['convexQuery', funcRef, args]`).
### Data Ownership Caveat
Don't render `preloadQuery` data in BOTH Server and Client components — the server-rendered part can't be revalidated by React Query. Prefer `prefetch` (let client own data) unless you need server-side access (metadata, 404, redirects).
---
## API Reference
### Infinite Query Return Value
| Property | Type | Description |
|----------|------|-------------|
| `data` | `T[]` | Flattened array of all items |
| `pages` | `T[][]` | Raw page arrays |
| `fetchNextPage` | `(limit?) => void` | Load next page |
| `hasNextPage` | `boolean` | More pages exist |
| `status` | `PaginationStatus` | `'LoadingFirstPage' \| 'LoadingMore' \| 'CanLoadMore' \| 'Exhausted'` |
| `isPlaceholderData` | `boolean` | Showing placeholder |