docs: add Trellis planning and project specs
This commit is contained in:
662
.trellis/spec/frontend/orpc-usage.md
Normal file
662
.trellis/spec/frontend/orpc-usage.md
Normal file
@@ -0,0 +1,662 @@
|
||||
# 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<ReturnType<(typeof orpcClient)["items"]["get"]>>;
|
||||
|
||||
export function useItem(itemId: string | null) {
|
||||
const hasItemId = typeof itemId === "string" && itemId.trim().length > 0;
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery<ItemResult | undefined>({
|
||||
...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<ReturnType<(typeof orpcClient)["items"]["list"]>>;
|
||||
type ListCursor = { lastUpdatedAt: string; id: string } | undefined;
|
||||
type ListQueryKey = ReturnType<typeof orpc.items.list.queryKey>;
|
||||
|
||||
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<ListResult, ListCursor>,
|
||||
ListQueryKey,
|
||||
ListCursor
|
||||
>({
|
||||
queryKey: orpc.items.list.queryKey({
|
||||
input: {
|
||||
categoryId: categoryId ?? "",
|
||||
filters,
|
||||
},
|
||||
}),
|
||||
queryFn: async ({ pageParam }): Promise<ListResult> => {
|
||||
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<ConnectOutput, Error, ConnectInput>({
|
||||
...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<CreateItemInput, "userId"> // 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<ReturnType<(typeof orpcClient)["items"]["get"]>>;
|
||||
|
||||
// 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 && <Skeleton />}
|
||||
{isPending && <Button disabled>Saving...</Button>}
|
||||
{isFetchingNextPage && <LoadingSpinner />}
|
||||
```
|
||||
|
||||
### 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
|
||||
Reference in New Issue
Block a user