# oRPC Frontend Usage Guidelines This document provides comprehensive guidelines for using oRPC in frontend applications, covering client setup, React Query integration, and best practices. ## 1. Overview oRPC (OpenAPI RPC) provides type-safe RPC-style API calls with automatic TypeScript type inference. When combined with React Query (TanStack Query), it offers a powerful solution for data fetching, caching, and state synchronization. **Key Benefits:** - End-to-end type safety from backend to frontend - Automatic query key generation - Seamless React Query integration - Built-in error handling ## 2. Client Setup ### Basic Client Configuration ```typescript // lib/orpc-client.ts import { createORPCClient, onError } from "@orpc/client"; import { RPCLink } from "@orpc/client/fetch"; import type { ApiRouterClient } from "@your-app/api/orpc/router"; // Replace with your monorepo package path const link = new RPCLink({ url: "/api/rpc", headers: async () => { // Client-side: return empty headers (cookies handled automatically) if (typeof window !== "undefined") { return {}; } // Server-side: forward request headers for SSR const { headers } = await import("next/headers"); return Object.fromEntries(await headers()); }, interceptors: [ onError((error) => { // Ignore abort errors (e.g., from React strict mode) if (error instanceof Error && error.name === "AbortError") { return; } console.error(error); }), ], }); export const orpcClient: ApiRouterClient = createORPCClient(link); ``` **Key Points:** - The `ApiRouterClient` type ensures full type safety - Headers handling differs between client and server environments - Error interceptors provide centralized error logging ## 3. React Query Integration ### Creating Query Utilities ```typescript // lib/orpc-query-utils.ts import { createTanstackQueryUtils } from "@orpc/tanstack-query"; import { orpcClient } from "./orpc-client"; export const orpc = createTanstackQueryUtils(orpcClient); ``` The `orpc` object provides utilities for generating query options and keys that integrate seamlessly with React Query. ## 4. Query Patterns ### 4.1 Basic Query with useQuery ```typescript import { orpc } from "@/lib/orpc-query-utils"; import { orpcClient } from "@/lib/orpc-client"; import { useQuery } from "@tanstack/react-query"; // Derive types from the client type ItemResult = Awaited>; export function useItem(itemId: string | null) { const hasItemId = typeof itemId === "string" && itemId.trim().length > 0; const { data, isLoading, error, refetch } = useQuery({ ...orpc.items.get.queryOptions({ input: { itemId: itemId ?? "" }, }), enabled: hasItemId, staleTime: 5 * 60 * 1000, // Cache for 5 minutes gcTime: 10 * 60 * 1000, // Keep in garbage collection for 10 minutes }); return { item: data?.item ?? null, isLoading, error, refetch, }; } ``` ### 4.2 Infinite Query with Cursor Pagination ```typescript import { orpc } from "@/lib/orpc-query-utils"; import { orpcClient } from "@/lib/orpc-client"; import { useInfiniteQuery } from "@tanstack/react-query"; import type { InfiniteData } from "@tanstack/react-query"; type ListResult = Awaited>; type ListCursor = { lastUpdatedAt: string; id: string } | undefined; type ListQueryKey = ReturnType; interface UseItemListOptions { categoryId: string | null; filters?: { isActive?: boolean; search?: string; }; enabled?: boolean; } export function useItemList(options: UseItemListOptions) { const { categoryId, filters, enabled = true } = options; const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, error, refetch, } = useInfiniteQuery< ListResult, Error, InfiniteData, ListQueryKey, ListCursor >({ queryKey: orpc.items.list.queryKey({ input: { categoryId: categoryId ?? "", filters, }, }), queryFn: async ({ pageParam }): Promise => { if (!categoryId) { throw new Error("Category ID is required"); } return await orpcClient.items.list({ categoryId, limit: 20, cursor: pageParam, filters, }); }, initialPageParam: undefined, getNextPageParam: (lastPage): ListCursor => lastPage.nextCursor ?? undefined, enabled: enabled && !!categoryId, }); // Flatten all pages into a single array const items = data?.pages.flatMap((page) => page.items) ?? []; return { items, hasNextPage, fetchNextPage, isFetchingNextPage, isLoading, error, refetch, }; } ``` ### 4.3 Batch Queries with useQueries ```typescript import { orpc } from "@/lib/orpc-query-utils"; import { useQueries } from "@tanstack/react-query"; import { useMemo } from "react"; interface UseBatchItemsOptions { itemIds: string[]; enabled?: boolean; staleTime?: number; } export function useBatchItems(options: UseBatchItemsOptions) { const { itemIds, enabled = true, staleTime = 5 * 60 * 1000 } = options; const queries = useQueries({ queries: itemIds.map((itemId) => ({ ...orpc.items.get.queryOptions({ input: { itemId }, }), enabled: enabled && !!itemId, staleTime, })), }); // Build a map for easy lookup const itemsMap = useMemo(() => { const map = new Map(); queries.forEach((query, index) => { const itemId = itemIds[index]; if (itemId && query.data) { map.set(itemId, { data: query.data, isLoading: query.isLoading, error: query.error, }); } }); return map; }, [queries, itemIds]); return { itemsMap, isLoading: queries.some((q) => q.isLoading), isAllLoaded: queries.every((q) => q.isSuccess || q.isError), }; } ``` ### 4.4 Query Key Management oRPC provides automatic query key generation: ```typescript // Get query key with input parameters const queryKey = orpc.items.list.queryKey({ input: { categoryId: "123", filters: { isActive: true } }, }); // Get base key (without input) for broader invalidation const baseKey = orpc.items.list.key(); // Usage in cache invalidation queryClient.invalidateQueries({ queryKey: orpc.items.list.key(), // Invalidates all items.list queries }); queryClient.invalidateQueries({ queryKey: orpc.items.list.queryKey({ input: { categoryId: "123" }, }), // Invalidates specific query }); ``` ## 5. Mutation Patterns ### 5.1 Basic Mutation ```typescript import { orpc } from "@/lib/orpc-query-utils"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; interface UseConnectServiceOptions { onSuccess?: (data: ConnectOutput) => void; onError?: (error: Error) => void; } export function useConnectService(options: UseConnectServiceOptions = {}) { const queryClient = useQueryClient(); return useMutation({ ...orpc.services.connect.mutationOptions(), onSuccess: (data) => { // Invalidate related queries queryClient.invalidateQueries({ queryKey: orpc.services.default.key(), }); options.onSuccess?.(data); }, onError: (error) => { toast.error("Connection failed", { description: error.message, }); options.onError?.(error); }, }); } ``` ### 5.2 Mutation with Optimistic Updates ```typescript import { orpc } from "@/lib/orpc-query-utils"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; export function useUpdateItem() { const queryClient = useQueryClient(); return useMutation< { success: boolean }, Error, { itemId: string; isActive: boolean }, { previousQueries: [readonly unknown[], unknown][] } >({ ...orpc.items.update.mutationOptions(), onMutate: async ({ itemId, isActive }) => { // Cancel outgoing refetches to avoid overwriting optimistic update await queryClient.cancelQueries({ queryKey: orpc.items.list.key(), }); // Snapshot current data for rollback const previousQueries = queryClient.getQueriesData({ queryKey: orpc.items.list.key(), }); // Optimistically update the cache queryClient.setQueriesData( { queryKey: orpc.items.list.key() }, (old: unknown) => { const data = old as { pages?: Array<{ items: Array<{ id: string; isActive: boolean }>; }>; }; if (!data?.pages) return old; return { ...data, pages: data.pages.map((page) => ({ ...page, items: page.items.map((item) => item.id === itemId ? { ...item, isActive } : item ), })), }; } ); return { previousQueries }; }, onError: (_error, _variables, context) => { // Rollback on error if (context?.previousQueries) { for (const [queryKey, data] of context.previousQueries) { queryClient.setQueryData(queryKey, data); } } toast.error("Failed to update item"); }, onSuccess: () => { // Optionally invalidate related queries queryClient.invalidateQueries({ queryKey: orpc.items.counts.key(), }); }, }); } ``` ### 5.3 Optimistic Delete (Remove from List) ```typescript export function useDeleteItem() { const queryClient = useQueryClient(); return useMutation< { success: boolean }, Error, { itemId: string }, { previousQueries: [readonly unknown[], unknown][] } >({ ...orpc.items.delete.mutationOptions(), onMutate: async ({ itemId }) => { await queryClient.cancelQueries({ queryKey: orpc.items.list.key(), }); const previousQueries = queryClient.getQueriesData({ queryKey: orpc.items.list.key(), }); // Optimistically remove from all lists queryClient.setQueriesData( { queryKey: orpc.items.list.key() }, (old: unknown) => { const data = old as { pages?: Array<{ items: Array<{ id: string }>; nextCursor: unknown; hasMore: boolean; }>; }; if (!data?.pages) return old; return { ...data, pages: data.pages.map((page) => ({ ...page, items: page.items.filter((item) => item.id !== itemId), })), }; } ); return { previousQueries }; }, onError: (_error, _variables, context) => { if (context?.previousQueries) { for (const [queryKey, data] of context.previousQueries) { queryClient.setQueryData(queryKey, data); } } toast.error("Failed to delete item"); }, onSuccess: () => { toast.success("Item deleted"); queryClient.invalidateQueries({ queryKey: orpc.items.counts.key(), }); }, }); } ``` ## 6. Direct Client Calls ### 6.1 When to Use Direct Client vs useMutation **Use `useMutation` when:** - You need loading/error states in UI - You want automatic retry behavior - You need optimistic updates - You want built-in cache invalidation hooks **Use direct `orpcClient` calls when:** - Inside `mutationFn` for custom logic (see 6.2) - In event handlers where you need sequential operations - When you need to transform input before calling API - In server components or API routes ### 6.2 Custom Mutation Function When you need to add custom logic, transform inputs, or handle complex scenarios: ```typescript import { orpcClient } from "@/lib/orpc-client"; import { orpc } from "@/lib/orpc-query-utils"; import { useMutation, useQueryClient } from "@tanstack/react-query"; export function useCreateItem(options = {}) { const { user } = useSession(); const queryClient = useQueryClient(); return useMutation< CreateItemOutput, Error, Omit // userId will be added automatically >({ mutationKey: orpc.items.create.mutationKey(), mutationFn: async (input) => { // Add authentication if (!user?.id) { throw new Error("User not authenticated"); } // Transform input before calling API const fullInput: CreateItemInput = { ...input, userId: user.id, }; // Direct client call with transformed input return orpcClient.items.create(fullInput); }, onSuccess: (data) => { if (data.success) { toast.success("Item created successfully"); queryClient.invalidateQueries({ queryKey: orpc.items.list.key(), }); options.onSuccess?.(data); } else { // Handle API-level errors const errorMessage = data.error || "Failed to create item"; toast.error("Failed to create item", { description: errorMessage, }); options.onError?.(new Error(errorMessage)); } }, onError: (error) => { toast.error("Failed to create item", { description: error.message, }); options.onError?.(error); }, }); } ``` ### 6.3 Type Inference Derive types directly from the client for maximum type safety: ```typescript import type { orpcClient } from "@/lib/orpc-client"; // Infer return type from client method type ItemResult = Awaited>; // Infer input type from client method type CreateItemInput = Parameters<(typeof orpcClient)["items"]["create"]>[0]; ``` ## 7. Best Practices ### 7.1 Query Key Consistency Always use oRPC's generated query keys for consistency: ```typescript // GOOD: Use generated query keys queryClient.invalidateQueries({ queryKey: orpc.items.list.key(), }); // GOOD: Use specific query key with input queryClient.invalidateQueries({ queryKey: orpc.items.list.queryKey({ input: { categoryId: "123" } }), }); // BAD: Manually constructed keys queryClient.invalidateQueries({ queryKey: ["items", "list"], // Don't do this }); ``` ### 7.2 Error Handling Implement consistent error handling with toast notifications: ```typescript import { toast } from "sonner"; // In mutation hooks onError: (error) => { toast.error("Operation failed", { description: error.message, }); }, // Handle API-level errors in onSuccess onSuccess: (data) => { if (!data.success) { toast.error("Operation failed", { description: data.error || "Unknown error", }); return; } // Handle success... }, ``` ### 7.3 Loading States Use appropriate loading state properties: ```typescript const { isLoading, isFetching, isPending } = useQuery(...); const { isPending, isSuccess, isError } = useMutation(...); const { isFetchingNextPage, hasNextPage } = useInfiniteQuery(...); // In components {isLoading && } {isPending && } {isFetchingNextPage && } ``` ### 7.4 Cache Configuration Set appropriate cache times based on data characteristics: ```typescript // Frequently changing data staleTime: 30 * 1000, // 30 seconds gcTime: 60 * 1000, // 1 minute // Moderately stable data staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, // 10 minutes // Stable/static data staleTime: 30 * 60 * 1000, // 30 minutes gcTime: 60 * 60 * 1000, // 1 hour ``` ### 7.5 Input Validation in Hooks Always validate inputs before making API calls: ```typescript export function useItem(itemId: string | null) { const hasItemId = typeof itemId === "string" && itemId.trim().length > 0; useEffect(() => { if (!hasItemId) { console.warn("[useItem] Invalid itemId provided. Request skipped."); } }, [hasItemId]); return useQuery({ ...orpc.items.get.queryOptions({ input: { itemId: itemId ?? "" }, }), enabled: hasItemId, // Prevent invalid requests }); } ``` ### 7.6 Partial Success Handling Handle batch operations that may partially succeed: ```typescript onSuccess: (result) => { if (result.failed === 0) { toast.success(`${result.processed} items updated`); } else if (result.processed > 0) { toast.warning( `${result.processed} of ${result.total} items updated, ${result.failed} failed` ); // Refresh to get correct state for failed items queryClient.invalidateQueries({ queryKey: orpc.items.list.key(), }); } else { toast.error("Failed to update items"); } }, ``` ## 8. Common Patterns Summary | Pattern | Hook | Use Case | |---------|------|----------| | Single item fetch | `useQuery` | Detail pages, single record | | List with pagination | `useInfiniteQuery` | Lists, feeds, search results | | Multiple items | `useQueries` | Batch preloading, related items | | Create/Update/Delete | `useMutation` | Form submissions, actions | | Optimistic updates | `useMutation` + `onMutate` | Real-time UI updates | | Custom mutation logic | `useMutation` + `mutationFn` | Auth injection, input transformation | ## 9. Migration Notes When migrating from other data fetching approaches: 1. Replace manual fetch calls with `orpcClient` methods 2. Replace manual query keys with `orpc.xxx.queryKey()` 3. Use `orpc.xxx.queryOptions()` and `mutationOptions()` for React Query integration 4. Leverage TypeScript inference from the client types