Queries and Mutations Architecture
This documentation explains how queries and mutations are handled in the codebase using Jotai, React Query, Suspense and hydration with Next.js.
Overview
The architecture combines several technologies:
- Jotai for state management
- oRPC for type-safe API calls
- React Query for server state management
- React Suspense for loading states
- Next.js for server-side rendering
Core Concepts
oRPC Setup
The application uses two oRPC clients:
orpc-vanilla-client.ts- Base oRPC client used by Jotai atoms and direct callsorpc-server.ts- Server-side helpers for SSR
Query Atoms
Queries are built using utility functions that create Jotai atoms:
- The base oRPC client is stored in a Jotai atom (
orpcAtom) that handles authentication tokens - Query atoms are created using helper functions that combine oRPC, React Query, and Jotai
- These atoms can be used with Suspense boundaries for loading states
- The
orpcQueryUtilsAtomprovides query utilities for cache invalidation and prefetching
Authentication Flow
The system handles authentication through:
- Server-side: Cookie-based session token in
orpc-server.ts - Client-side: Token management via
authTokenAtomandauthTokenGetterAtom
Hydration Process
-
Server-side rendering:
orpcSSRcreates server-side helpers- Initial data is prefetched and serialized
- QueryClient is populated with initial data
-
Client-side hydration:
- React Query rehydrates from serialized state
- Jotai atoms initialize with prefetched data
- Components render with cached data first
Implementation Details
Server-Side Setup
The server-side oRPC setup (orpc-server.ts) handles SSR and authentication:
export const orpcSSR = (queryClient: QueryClient) => {
const token = cookies().get('__session')
return createServerSideHelpers({
client: getProxyClient(token?.value),
queryClient,
})
}Provider Architecture
The application uses a layered provider architecture to handle state management:
-
Business Providers (
business-providers.tsx):- Sets up the root providers for the application
- Configures React Query with SSR-friendly defaults
- Initializes Jotai store and auth state
-
Jotai Query Provider (
jotai-query-provider.tsx):- Creates a scoped Jotai store
- Manages auth token and query client state
- Enables component-level state isolation
Query Atom Creation
The makeQueryAtoms utility (make-query-atoms.ts) provides a standardized way to create query atoms:
const [queryAtom, statusAtom] = makeQueryAtoms(
['domain', 'contextQueryKey'],
(get) => params,
(orpc, params) => orpc.someEndpoint.queryOptions(params)
)This creates:
- A suspense-enabled query atom
- A status atom for tracking fetch state
oRPC Query and Mutation Options
oRPC provides .queryOptions() and .mutationOptions() methods that return React Query-compatible options objects:
// Query options - returns { queryKey, queryFn, ... }
const options = orpc.inventory.getProducts.queryOptions({ limit: 10 })
// Use with useQuery or query atoms
const { data } = useQuery(options)
// Mutation options - returns { mutationKey, mutationFn, ... }
const mutationOptions = orpc.inventory.updateProduct.mutationOptions()
// Use with useMutation
const mutation = useMutation(mutationOptions)Direct oRPC Calls
For simple cases, you can call oRPC endpoints directly:
// Direct call (returns promise)
const products = await orpc.inventory.getProducts({ limit: 10 })
// With the orpc client from atom
const orpc = get(orpcAtom)
const result = await orpc.inventory.getProducts({ limit: 10 })Practical Usage Examples
1. Inventory Management
The inventory UI demonstrates a complex implementation:
// Atom creation
export const [productsAtom, productsStatusAtom] = makeQueryAtoms(
['inventory', 'all'],
(get) => get(fetchInventoryProductsParamsAtom),
(orpc, params) => orpc.inventory.getProducts.queryOptions(params)
)
// Component usage
const InventoryProducts = () => {
const products = useAtomValue(productsSwrAtom)
const status = useAtomValue(productsStatusAtom)
// ... render logic
}2. Mutations
Mutations are handled similarly, as shown in the edit inventory dialog:
const EditInventoryLine = () => {
const { mutateAsync: editInventoryLine } = useAtomValue(editInventoryLineMutation)
const handleSubmit = async () => {
await editInventoryLine()
// Invalidate relevant queries
await queryClient.invalidateQueries({
queryKey: queries.inventory._def
})
}
}Error Handling
oRPC provides utilities for type-safe error handling with isDefinedError() and safe() from @orpc/client:
Using safe() for Error Handling
The safe() utility wraps async calls and returns a tuple of [error, data]:
import { safe, isDefinedError } from '@orpc/client'
const handleSubmit = async () => {
const [error, data] = await safe(mutation.mutateAsync(input))
if (error) {
if (isDefinedError(error)) {
// Type-safe error handling for oRPC defined errors
switch (error.code) {
case 'ITEM_NOT_FOUND':
toast.error(`Item ${error.data.itemId} not found`)
break
case 'INSUFFICIENT_QUANTITY':
toast.error(`Only ${error.data.available} items available`)
break
}
} else {
// Generic error handling
toast.error('An unexpected error occurred')
}
return
}
// Success - data is typed
toast.success(`Created item ${data.id}`)
}Using isDefinedError() for Type Guards
The isDefinedError() function is a type guard that narrows the error type to oRPC’s defined errors:
import { isDefinedError } from '@orpc/client'
try {
await orpc.cart.addItem({ itemId: '123' })
} catch (error) {
if (isDefinedError(error)) {
// error.code and error.data are now typed
console.log(error.code) // e.g., 'ITEM_NOT_FOUND'
console.log(error.data) // e.g., { itemId: '123' }
}
}Query Utilities with orpcQueryUtilsAtom
The orpcQueryUtilsAtom provides utilities for cache management and prefetching:
const queryUtils = useAtomValue(orpcQueryUtilsAtom)
// Invalidate queries
await queryUtils.inventory.getProducts.invalidate()
// Prefetch data
await queryUtils.inventory.getProducts.prefetch({ limit: 10 })
// Set query data directly
queryUtils.inventory.getProducts.setData({ limit: 10 }, newData)Best Practices for Implementation
-
Query Organization
- Group related queries in feature-specific files
- Use consistent naming patterns for atoms
- Implement proper error boundaries
-
State Management
- Use atoms for local UI state
- Leverage React Query for server state
- Implement proper loading states with Suspense
-
Performance Optimization
- Configure appropriate stale times
- Implement query invalidation strategies
- Use proper caching policies
-
Type Safety
- Leverage oRPC’s type inference
- Define proper input/output types in
@repo/api-dtos - Use TypeScript strict mode
- Use
isDefinedError()for typed error handling
-
Error Handling
- Use
safe()wrapper for cleaner error handling - Handle defined errors with type-safe switches
- Provide meaningful error messages to users
- Use
Common Patterns
- Query with Status
const [dataAtom, statusAtom] = makeQueryAtoms(
['domain', 'contextQueryKey'],
getParams,
(orpc, params) => orpc.domain.endpoint.queryOptions(params)
)
// Usage
const data = useAtomValue(dataAtom)
const status = useAtomValue(statusAtom)- Mutation with Invalidation
const mutation = useSetAtom(mutationAtom)
const queryClient = useAtomValue(queryClientAtom)
const handleMutate = async () => {
const [error, data] = await safe(mutation(input))
if (error) {
if (isDefinedError(error)) {
// Handle typed error
}
return
}
await queryClient.invalidateQueries({
queryKey: queries.domain._def
})
}- Suspense Boundary Setup
<Suspense fallback={<LoadingComponent />}>
<QueryComponent />
</Suspense>- Direct oRPC Call with Error Handling
const orpc = useAtomValue(orpcAtom)
const fetchData = async () => {
const [error, data] = await safe(orpc.inventory.getProduct({ id }))
if (isDefinedError(error)) {
// Handle specific error codes
}
return data
}This architecture provides a robust foundation for building complex applications with server state management, while maintaining type safety and optimal performance characteristics.