docs: add Trellis planning and project specs
This commit is contained in:
328
.trellis/spec/frontend/hooks.md
Normal file
328
.trellis/spec/frontend/hooks.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# Hook Development Patterns
|
||||
|
||||
This document covers React hook patterns for data fetching, mutations, and state management using React Query with oRPC.
|
||||
|
||||
## Query Hooks
|
||||
|
||||
### Basic Query Pattern
|
||||
|
||||
```typescript
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { orpcClient } from '@/lib/orpc';
|
||||
|
||||
export function useUsers() {
|
||||
return useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => orpcClient.users.list(),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Query with Parameters
|
||||
|
||||
```typescript
|
||||
export function useUser(userId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['users', userId],
|
||||
queryFn: () => orpcClient.users.get({ id: userId }),
|
||||
enabled: !!userId, // Only fetch when userId is available
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Query with Filters
|
||||
|
||||
```typescript
|
||||
interface UseOrdersOptions {
|
||||
status?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export function useOrders(options: UseOrdersOptions = {}) {
|
||||
const { status, page = 1, pageSize = 20 } = options;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['orders', { status, page, pageSize }],
|
||||
queryFn: () => orpcClient.orders.list({ status, page, pageSize }),
|
||||
placeholderData: (previousData) => previousData, // Keep previous data while fetching
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Mutation Hooks
|
||||
|
||||
### Basic Mutation Pattern
|
||||
|
||||
```typescript
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { orpcClient } from '@/lib/orpc';
|
||||
|
||||
export function useCreateUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateUserInput) => orpcClient.users.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Mutation with Optimistic Updates
|
||||
|
||||
```typescript
|
||||
type OrderListData = Awaited<ReturnType<typeof orpcClient.orders.list>>;
|
||||
|
||||
export function useUpdateOrderStatus() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
||||
orpcClient.orders.updateStatus({ id, status }),
|
||||
|
||||
onMutate: async ({ id, status }) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: ['orders'] });
|
||||
|
||||
// Snapshot previous value
|
||||
const previousOrders = queryClient.getQueryData<OrderListData>(['orders']);
|
||||
|
||||
// Optimistically update
|
||||
queryClient.setQueryData<OrderListData>(['orders'], (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
items: old.items.map((order) =>
|
||||
order.id === id ? { ...order, status } : order
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return { previousOrders };
|
||||
},
|
||||
|
||||
onError: (_err, _variables, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousOrders) {
|
||||
queryClient.setQueryData(['orders'], context.previousOrders);
|
||||
}
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
// Always refetch after mutation
|
||||
queryClient.invalidateQueries({ queryKey: ['orders'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Overriding Mutation Callbacks
|
||||
|
||||
When overriding mutation callbacks at the call site, you MUST add explicit generics to maintain type safety:
|
||||
|
||||
### Problem: Lost Type Safety
|
||||
|
||||
```typescript
|
||||
// Hook definition
|
||||
export function useDeleteUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => orpcClient.users.delete({ id }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Bad: Overriding without generics loses type safety
|
||||
const deleteUser = useDeleteUser();
|
||||
deleteUser.mutate(userId, {
|
||||
onSuccess: (data) => {
|
||||
// 'data' is typed as 'unknown' here!
|
||||
console.log(data.id); // TypeScript error or runtime error
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Solution: Explicit Generics
|
||||
|
||||
```typescript
|
||||
// Infer types for the mutation
|
||||
type DeleteUserData = Awaited<ReturnType<typeof orpcClient.users.delete>>;
|
||||
type DeleteUserVariables = string;
|
||||
|
||||
// Good: Add explicit generics when overriding callbacks
|
||||
deleteUser.mutate<DeleteUserData, Error, DeleteUserVariables>(userId, {
|
||||
onSuccess: (data) => {
|
||||
// 'data' is properly typed
|
||||
console.log(data.id); // Works correctly
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Alternative: Define Types in Hook
|
||||
|
||||
```typescript
|
||||
// Export types from the hook file
|
||||
export type DeleteUserMutationData = Awaited<
|
||||
ReturnType<typeof orpcClient.users.delete>
|
||||
>;
|
||||
|
||||
// Usage with exported types
|
||||
deleteUser.mutate(userId, {
|
||||
onSuccess: (data: DeleteUserMutationData) => {
|
||||
console.log(data.id);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Using orpcClient Directly in Hooks
|
||||
|
||||
Inside hooks, use `orpcClient` directly instead of wrapping with `useMutation`:
|
||||
|
||||
### DO: Direct orpcClient Usage
|
||||
|
||||
```typescript
|
||||
export function useOrderActions() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateOrder = useMutation({
|
||||
mutationFn: (data: UpdateOrderInput) => orpcClient.orders.update(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['orders'] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteOrder = useMutation({
|
||||
mutationFn: (id: string) => orpcClient.orders.delete({ id }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['orders'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
updateOrder: updateOrder.mutate,
|
||||
deleteOrder: deleteOrder.mutate,
|
||||
isUpdating: updateOrder.isPending,
|
||||
isDeleting: deleteOrder.isPending,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### DON'T: Nested Hooks
|
||||
|
||||
```typescript
|
||||
// Bad: Don't create hooks that use other mutation hooks
|
||||
export function useOrderActions() {
|
||||
// Don't do this - creates unnecessary abstraction
|
||||
const updateMutation = useUpdateOrder();
|
||||
const deleteMutation = useDeleteOrder();
|
||||
|
||||
return {
|
||||
updateOrder: updateMutation.mutate,
|
||||
deleteOrder: deleteMutation.mutate,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Compound Hooks
|
||||
|
||||
Combine related queries and mutations into a single hook:
|
||||
|
||||
```typescript
|
||||
export function useProduct(productId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['products', productId],
|
||||
queryFn: () => orpcClient.products.get({ id: productId }),
|
||||
enabled: !!productId,
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: (data: UpdateProductInput) =>
|
||||
orpcClient.products.update({ id: productId, ...data }),
|
||||
onSuccess: (updatedProduct) => {
|
||||
queryClient.setQueryData(['products', productId], updatedProduct);
|
||||
},
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => orpcClient.products.delete({ id: productId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
product: query.data,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
updateProduct: update.mutate,
|
||||
deleteProduct: remove.mutate,
|
||||
isUpdating: update.isPending,
|
||||
isDeleting: remove.isPending,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Infinite Query Pattern
|
||||
|
||||
```typescript
|
||||
export function useInfiniteOrders() {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ['orders', 'infinite'],
|
||||
queryFn: ({ pageParam = 1 }) =>
|
||||
orpcClient.orders.list({ page: pageParam, pageSize: 20 }),
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.hasMore ? lastPage.page + 1 : undefined,
|
||||
initialPageParam: 1,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Dependent Queries
|
||||
|
||||
```typescript
|
||||
export function useUserOrders(userId: string) {
|
||||
// First query: get user
|
||||
const userQuery = useQuery({
|
||||
queryKey: ['users', userId],
|
||||
queryFn: () => orpcClient.users.get({ id: userId }),
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
// Second query: depends on user data
|
||||
const ordersQuery = useQuery({
|
||||
queryKey: ['orders', { userId }],
|
||||
queryFn: () => orpcClient.orders.list({ userId }),
|
||||
enabled: !!userQuery.data, // Only run when user is loaded
|
||||
});
|
||||
|
||||
return {
|
||||
user: userQuery.data,
|
||||
orders: ordersQuery.data,
|
||||
isLoading: userQuery.isLoading || ordersQuery.isLoading,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Single Responsibility**: Each hook should have one clear purpose
|
||||
2. **Consistent Naming**: `useXxx` for hooks, `useXxxQuery` for queries, `useXxxMutation` for mutations
|
||||
3. **Error Handling**: Always consider error states in your hooks
|
||||
4. **Loading States**: Expose loading states for UI feedback
|
||||
5. **Cache Keys**: Use consistent, hierarchical query keys
|
||||
6. **Type Safety**: Always maintain proper TypeScript types
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Forgetting to invalidate related queries after mutations
|
||||
- Not handling race conditions with `cancelQueries`
|
||||
- Missing `enabled` flag for conditional queries
|
||||
- Not providing explicit generics when overriding callbacks
|
||||
- Creating too many small hooks instead of compound hooks
|
||||
Reference in New Issue
Block a user