UNPKG

kitcn

Version:

kitcn - React Query integration and CLI tools for Convex

673 lines (521 loc) 19.8 kB
# 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 componentsthe 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 |