Reflections
After completing this module (or if you skipped to this page, that's fine too!), take some time to reflect on what you've learned and how you can apply these concepts in future projects. Consider the following questions:
- What was the most challenging aspect of implementing CRUD operations in this module?
- How did you ensure the security and integrity of user data during CRUD operations?
- What best practices did you follow to make your CRUD implementation scalable and maintainable?
- How can you apply the knowledge gained from this module to other areas of your work or future projects?
- Are there any additional features or improvements you would like to implement in the CRUD functionality you built?
You would also have explored the lab repository's codebase and understood how different components interact to provide a seamless user experience. Reflect on the architecture and design patterns used in the implementation. Consider how these patterns can be applied to other projects you work on in the future.
Heavy read ahead, but very important for building robust CRUD applications! Please go through the entire section to understand best practices for building CRUD features.
Architecture Overview
Let us briefly revisit the architecture of the CRUD application you built upon in this module. The lab's codebase follows a layered architecture, separating concerns into distinct layers:
Form Handling
Why React Hook Form?
This codebase uses React Hook Form with Zod resolver for form handling because:
- Minimal re-renders (uncontrolled inputs by default)
- Built-in validation integration
- TypeScript support
- Easy error handling
The documentation for React Hook Form can be found here. It is recommended to go through the official docs for a deeper understanding of its features and capabilities.
You should also read up on how to use the Zod Resolver to integrate Zod validation with React Hook Form.
Basic Form Setup
// apps/web/src/app/(authed)/dashboard/_components/add-thread-modal.tsx
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { createThreadInputSchema } from "~/validators/thread";
export const AddThreadModal = () => {
const { control, handleSubmit } = useForm({
resolver: zodResolver(createThreadInputSchema), // ← Same schema as backend!
defaultValues: {
title: "",
content: "",
},
});
// ...
};
Controller Pattern for Custom Components
Use Controller when working with custom UI components that don't expose standard input refs:
<Controller
name="title"
control={control}
render={({ field, fieldState: { error } }) => (
<TextField
isInvalid={!!error} // Show error state
errorMessage={error?.message} // Display error message
inputProps={{
placeholder: "Enter thread title",
}}
label="Title"
{...field} // Spread field props (value, onChange, etc.)
/>
)}
/>
Form Submission with Mutations
Connect form submission to tRPC mutations:
<form
onSubmit={handleSubmit(
(values) => createThreadMutation.mutate(values) // Type-safe mutation call
)}
>
{/* form fields */}
</form>
Handling Success and Reset
Reset form state and close modals on successful submission:
const createCommentMutation = useMutation(
trpc.comment.create.mutationOptions({
onSuccess: (newComment) => {
// Update cache, show toast, etc.
},
}),
)
// In the form submission:
<form
onSubmit={handleSubmit((values) =>
createCommentMutation.mutate(
{ threadId, content: values.content },
{
onSuccess: () => {
reset() // Reset form fields
onClose() // Close modal
},
},
),
)}
>
Partial Schema Validation
When some fields are provided externally (like threadId from URL params), use .omit():
const { control, handleSubmit, reset } = useForm({
resolver: zodResolver(createCommentInputSchema.omit({ threadId: true })),
defaultValues: {
content: "",
},
});
Footguns when Managing Cache in Server Side Rendered Apps
Please read React Query's Advanced Server Rendering docs before proceeding to understand the different types of server rendering and what footguns to avoid.
When using React Query with Server-Side Rendering (SSR) and Server Components in Next.js, there are several pitfalls that can cause data inconsistencies, memory leaks, or poor performance. This section summarizes the key footguns from the TanStack Query Advanced SSR Guide and how the foundation codebase addressed them. You should double check that your implementation of the RUD operations follows these best practices!
Footgun 1: Shared QueryClient Across Requests (Memory Leak & Data Leakage)
The Problem:
If you create a single QueryClient instance at the module level on the server, it will be shared across all requests. This causes:
- Memory leaks: The cache grows indefinitely as more queries are executed
- Data leakage: User A's data could be served to User B
❌ Bad: Shared QueryClient
// This is created once and shared across ALL requests - DANGEROUS!
const queryClient = new QueryClient();
export function Providers({ children }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
✅ How the lab repository solves it:
// apps/web/src/trpc/react.tsx
let clientQueryClientSingleton: QueryClient | undefined = undefined;
const getQueryClient = () => {
if (typeof window === "undefined") {
// Server: always make a new query client for each request
return createQueryClient();
} else {
// Browser: use singleton pattern to keep the same query client
return (clientQueryClientSingleton ??= createQueryClient());
}
};
The key insight: Server always creates a new client, browser reuses a singleton.
Footgun 2: Using useState for QueryClient with Suspense
The Problem:
If you use useState to create the QueryClient and there's no Suspense boundary between the provider and suspending components, React will throw away the client on initial render if it suspends.
❌ Bad: useState without Suspense boundary
export function Providers({ children }) {
// React will throw this away if a child suspends!
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
{children} {/* If this suspends, queryClient is lost */}
</QueryClientProvider>
);
}
✅ How the lab repository solves it:
// apps/web/src/trpc/react.tsx
export function TRPCReactProvider(props: { children: React.ReactNode }) {
// Uses getQueryClient() instead of useState
const queryClient = getQueryClient()
// useState is only used for the trpcClient, which is fine
const [trpcClient] = useState(() => createTRPCClient<AppRouter>({...}))
return (
<QueryClientProvider client={queryClient}>
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
{props.children}
</TRPCProvider>
</QueryClientProvider>
)
}
Footgun 3: Immediate Refetch After Hydration (staleTime: 0)
The Problem:
By default, React Query considers data stale immediately (staleTime: 0). With SSR, this means:
- Server prefetches data
- Data is sent to client and hydrated
- Client immediately refetches the same data (wasteful!)
❌ Bad: Default staleTime
const queryClient = new QueryClient();
// staleTime defaults to 0, causing immediate refetch after hydration
✅ How the lab repository solves it:
// apps/web/src/trpc/query-client.ts
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 30 * 1000, // 30 seconds
retry: false,
},
},
});
Footgun 4: Data Ownership Confusion (Server vs Client)
The Problem:
If you render data from a query both in a Server Component AND a Client Component, they can get out of sync when the client revalidates:
// Server Component
export default async function PostsPage() {
const posts = await queryClient.fetchQuery({...})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
{/* This is rendered on server and won't update */}
<div>Nr of posts: {posts.length}</div>
{/* This will update when client revalidates */}
<Posts />
</HydrationBoundary>
)
}
After client revalidation, posts.length (server-rendered) and the <Posts /> list (client-rendered) will be out of sync!
✅ How the lab repository solves it:
Server Components are used only for prefetching, not for rendering query data:
// apps/web/src/app/(authed)/threads/[threadId]/page.tsx
export default async function ThreadPage({
params,
}: DynamicPageProps<"threadId">) {
const { threadId } = await params;
const queryClient = getQueryClient();
// fetchQuery is ONLY used for validation (checking if thread exists)
const thread = await queryClient.fetchQuery(
trpc.thread.getById.queryOptions({ id: threadId })
);
if (!thread) {
notFound(); // Handle not found on server
}
// Prefetch for hydration - data is NOT rendered in this component
await prefetch(
trpc.comment.getCommentsByThreadId.infiniteQueryOptions({ threadId })
);
return (
<HydrateClient>
{/* Client Components own and render the data */}
<ThreadOp id={threadId} />
<ThreadComments id={threadId} />
</HydrateClient>
);
}
Rule of thumb: Server Components prefetch, Client Components render.
Footgun 5: Not Dehydrating Pending Queries (Streaming Support)
The Problem:
By default, only successful queries are dehydrated. If you want to stream data to the client as queries resolve, pending queries need to be included.
✅ How the lab repository solves it:
// apps/web/src/trpc/query-client.ts
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
dehydrate: {
serializeData: SuperJSON.serialize,
// Include pending queries in dehydration for streaming
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === "pending",
shouldRedactErrors: () => {
// We should not catch Next.js server errors
// as that's how Next.js detects dynamic pages
return false;
},
},
hydrate: {
deserializeData: SuperJSON.deserialize,
},
},
});
Footgun 6: Non-Serializable Data Types
The Problem:
React Query data must be serialized when passing from server to client. Complex types like Date, BigInt, or custom classes won't survive JSON serialization.
✅ How the lab repository solves it:
Using SuperJSON for serialization handles this automatically:
// apps/web/src/trpc/query-client.ts
dehydrate: {
serializeData: SuperJSON.serialize, // Handles Date, BigInt, etc.
},
hydrate: {
deserializeData: SuperJSON.deserialize,
},
Footgun 7: Request-Scoped QueryClient Not Being Scoped
The Problem:
When using a shared getQueryClient() function across Server Components, you need to ensure it's scoped per-request using React's cache().
✅ How the lab repository solves it:
// apps/web/src/trpc/server.tsx
import { cache } from "react";
// cache() is scoped per request, so we don't leak data between requests
export const getQueryClient = cache(createQueryClient);
The cache() function ensures the same QueryClient is reused within a single request but a new one is created for each new request.
Summary: SSR Cache Management Checklist
| Footgun | Solution | File |
|---|---|---|
| Shared QueryClient on server | Create new client per request on server, singleton on browser | trpc/react.tsx |
| useState with Suspense | Use getQueryClient() function instead | trpc/react.tsx |
| Immediate refetch after hydration | Set staleTime > 0 | trpc/query-client.ts |
| Server/Client data ownership confusion | Only prefetch in Server Components, render in Client Components | Page components |
| Pending queries not streamed | shouldDehydrateQuery includes pending | trpc/query-client.ts |
| Non-serializable data | Use SuperJSON for serialize/deserialize | trpc/query-client.ts |
| Request-scoped client not scoped | Wrap with React cache() | trpc/server.tsx |
Query Cache Invalidation
Understanding Cache Invalidation
Query cache invalidation is crucial for keeping your UI in sync with the server. The key decision is: when to refetch vs. when to update the cache directly.
Strategy 1: Simple Invalidation (Refetch)
Use invalidateQueries when:
- The mutation affects multiple queries
- The response structure is complex
- You want to ensure fresh data
// apps/web/src/app/(authed)/dashboard/_components/add-thread-modal.tsx
const createThreadMutation = useMutation(
trpc.thread.create.mutationOptions({
onSuccess: ({ id }) => {
toast.success("Thread created successfully");
void queryClient.invalidateQueries({
queryKey: trpc.thread.getAll.infiniteQueryKey(), // Refetch all threads
});
router.push(`/threads/${id}`);
},
})
);
Strategy 2: Optimistic Updates (Direct Cache Manipulation)
Use setQueryData when:
- You want instant UI feedback
- The mutation response contains the new data
- You want to avoid unnecessary network requests
// apps/web/src/app/(authed)/threads/[threadId]/_components/add-comment-modal.tsx
const createCommentMutation = useMutation(
trpc.comment.create.mutationOptions({
onSuccess: (newComment) => {
// 1. Update comments list directly
queryClient.setQueryData(
trpc.comment.getCommentsByThreadId.infiniteQueryKey({ threadId }),
(oldData) => {
if (!oldData) return oldData;
// Add new comment to the first page
const updatedPages = oldData.pages.map((page, index) => {
if (index === 0) {
return {
...page,
comments: [newComment, ...page.comments],
};
}
return page;
});
return { ...oldData, pages: updatedPages };
}
);
// 2. Update related thread's comment count
queryClient.setQueryData(
trpc.thread.getById.queryKey({ id: threadId }),
(oldThreadDetails) => {
if (!oldThreadDetails) return oldThreadDetails;
return {
...oldThreadDetails,
_count: {
comments: oldThreadDetails._count.comments + 1,
},
};
}
);
// 3. Invalidate list queries where calculating updates is complex
void queryClient.invalidateQueries({
queryKey: trpc.thread.getAll.infiniteQueryKey(),
});
},
})
);
When to Use Each Strategy
| Scenario | Strategy | Why |
|---|---|---|
| Creating new item | Invalidate list queries | Position in list may depend on sorting |
| Updating single item | Direct cache update | Response contains updated data |
| Deleting item | Direct cache update + invalidate | Remove from cache, refresh counts |
| Complex relationships | Invalidate affected queries | Easier to maintain |
| Paginated lists | Invalidate | Recalculating pagination is complex |
Handling Duplicates with Optimistic Updates
When adding items optimistically to paginated lists, you may encounter duplicates if the user loads more pages. Handle this in the UI:
// apps/web/src/app/(authed)/threads/[threadId]/_components/thread-comments.tsx
const uniqueComments = useMemo(() => {
const allComments = data?.pages.flatMap((page) => page.comments) ?? [];
const seenIds = new Set();
return allComments.filter((comment) => {
if (seenIds.has(comment.id)) {
return false; // Duplicate, filter it out
}
seenIds.add(comment.id);
return true;
});
}, [data]);
Query Key Conventions
Use tRPC's generated query key helpers for type safety:
// For a specific query
trpc.thread.getById.queryKey({ id: threadId });
// For infinite queries (pagination)
trpc.thread.getAll.infiniteQueryKey();
trpc.comment.getCommentsByThreadId.infiniteQueryKey({ threadId });
Data Fetching Patterns
This section covers the complete data fetching strategy used in the Threads feature, from server-side prefetching to client-side queries.
The SSR Data Flow
Pattern 1: Simple Prefetch + List Rendering
Use for pages that display a list of items (Dashboard → Threads list):
Step 1: Server Component (Page)
// apps/web/src/app/(authed)/dashboard/page.tsx
import { prefetch, trpc } from "~/trpc/server";
import { AddThreadModal } from "./_components/add-thread-modal";
import { ThreadsList } from "./_components/threads-list";
export default async function DashboardPage() {
// Prefetch threads data - will be available instantly on client
await prefetch(trpc.thread.getAll.infiniteQueryOptions({}));
return (
<>
<AddThreadModal />
<ThreadsList />
</>
);
}
Step 2: Client Component (List)
// apps/web/src/app/(authed)/dashboard/_components/threads-list.tsx
"use client";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useTRPC } from "~/trpc/react";
export const ThreadsList = () => {
const trpc = useTRPC();
// Data is already hydrated from server - no loading flash!
const { data, fetchNextPage, isFetching, hasNextPage } = useInfiniteQuery(
trpc.thread.getAll.infiniteQueryOptions(
{},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
)
);
// Handle empty state
if (data?.pages[0]?.threads.length === 0) {
return (
<EmptyPlaceholder
title="No threads yet"
description="Create a new thread to get started!"
/>
);
}
return (
<div className="flex flex-col gap-6">
<ul className="flex flex-col gap-4">
{data?.pages.map((page) =>
page.threads.map((thread) => (
<li key={thread.id}>
<Link href={`/threads/${thread.id}`}>
<ThreadCard thread={thread} />
</Link>
</li>
))
)}
</ul>
{/* Load more button */}
{hasNextPage && (
<Button
variant="reverse"
onPress={() => fetchNextPage()}
isPending={isFetching}
>
Load More
</Button>
)}
</div>
);
};
Pattern 2: Fetch + Validate + Prefetch Related Data
Use when you need to:
- Validate that a resource exists before rendering
- Prefetch related data (e.g., thread details + comments)
Server Component (Page with Validation)
// apps/web/src/app/(authed)/threads/[threadId]/page.tsx
import { notFound } from "next/navigation";
import { getQueryClient, HydrateClient, prefetch, trpc } from "~/trpc/server";
import type { DynamicPageProps } from "~/types/nextjs";
export default async function ThreadPage({
params,
}: DynamicPageProps<"threadId">) {
const { threadId } = await params;
// Step 1: Get the request-scoped QueryClient
const queryClient = getQueryClient();
// Step 2: Fetch thread AND validate existence
// Using fetchQuery (not prefetch) because we need the return value
const thread = await queryClient.fetchQuery(
trpc.thread.getById.queryOptions({
id: threadId,
})
);
// Step 3: Handle not found BEFORE rendering
if (!thread) {
notFound(); // Triggers /threads/[threadId]/not-found.tsx
}
// Step 4: Prefetch related data (comments)
// Using prefetch because we don't need the return value
await prefetch(
trpc.comment.getCommentsByThreadId.infiniteQueryOptions({
threadId,
})
);
// Step 5: Wrap children in HydrateClient to send cache to client
return (
<HydrateClient>
<div className="flex items-center justify-between gap-4">
<BackButton />
<AddCommentModal threadId={threadId} />
</div>
<div className="flex flex-col gap-6">
<ThreadOp id={threadId} />
<ThreadComments id={threadId} />
</div>
</HydrateClient>
);
}
Why use fetchQuery vs prefetch?
| Method | Returns Data | Populates Cache | Use Case |
|---|---|---|---|
fetchQuery | ✅ Yes | ✅ Yes | When you need to validate/use data |
prefetch | ❌ No | ✅ Yes | When you just want data in cache |
Pattern 3: Client-Side Single Item Query with Suspense
This pattern requires that the data was prefetched on the server. If not, use useQuery instead and handle loading states.
If the data was not prefetched, useSuspenseQuery will run twice - once on the server (when it is hydrating the client component), and once on the client (to fetch the data). This can lead to duplicate network requests. In addition, the server fetch may throw errors that will not be handled properly (like when authentication is required, but the server will not have the requisite cookies or headers).
If you are unsure, prefer useQuery for safety.
Use useSuspenseQuery when:
- The data was prefetched on the server
- You want automatic Suspense loading states
- The component should suspend until data is ready
// apps/web/src/app/(authed)/threads/[threadId]/_components/thread-op.tsx
"use client";
import { notFound } from "next/navigation";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useTRPC } from "~/trpc/react";
import { ThreadCard } from "~/app/(authed)/_components/thread-card";
interface ThreadOpProps {
id: string;
}
export const ThreadOp = ({ id }: ThreadOpProps) => {
const trpc = useTRPC();
// useSuspenseQuery guarantees data is available (no undefined check needed)
// If prefetched on server, this returns instantly from hydrated cache
const { data } = useSuspenseQuery(
trpc.thread.getById.queryOptions({
id,
})
);
// Handle deleted thread (data could become null after mutation)
if (!data) {
notFound();
}
return <ThreadCard thread={data} />;
};
Key insight: useSuspenseQuery differs from useQuery:
useQuery:datacan beundefined, requires loading state handlinguseSuspenseQuery:datais guaranteed to exist, suspends until ready
Pattern 4: Infinite Query with Deduplication
When using infinite queries with optimistic updates, duplicates can occur:
// apps/web/src/app/(authed)/threads/[threadId]/_components/thread-comments.tsx
"use client";
import { useMemo } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useTRPC } from "~/trpc/react";
export const ThreadComments = ({ id }: { id: string }) => {
const trpc = useTRPC();
const { data, fetchNextPage, isFetching, hasNextPage } = useInfiniteQuery(
trpc.comment.getCommentsByThreadId.infiniteQueryOptions(
{ threadId: id },
{ getNextPageParam: (lastPage) => lastPage.nextCursor }
)
);
// IMPORTANT: Deduplicate to handle optimistic update edge cases
// When you optimistically add an item to page 1, then user loads page 2
// (which was fetched before the item existed), there won't be duplicates.
// But if user navigates away and back, or cache invalidates, duplicates can occur.
const uniqueComments = useMemo(() => {
const allComments = data?.pages.flatMap((page) => page.comments) ?? [];
const seenIds = new Set<string>();
return allComments.filter((comment) => {
if (seenIds.has(comment.id)) {
return false; // Duplicate, skip
}
seenIds.add(comment.id);
return true;
});
}, [data]);
if (uniqueComments.length === 0) {
return (
<EmptyPlaceholder
title="No comments found"
description="Add a comment to start the discussion."
/>
);
}
return (
<div className="flex flex-col gap-2">
{uniqueComments.map((comment) => (
<div
key={comment.id}
className="rounded-sm bg-white px-6 py-3 shadow-sm"
>
<span>{comment.author.name ?? comment.author.email}</span>
<span>{comment.content}</span>
</div>
))}
{hasNextPage && (
<Button
variant="clear"
onPress={() => fetchNextPage()}
isPending={isFetching}
>
Load more comments
</Button>
)}
</div>
);
};
Pattern 5: The useTRPC Hook
Always get the tRPC client using the useTRPC hook in client components:
// ❌ Wrong: Importing trpc directly in client component
// ✅ Correct: Using the useTRPC hook
import { useTRPC } from "~/trpc/react";
import { trpc } from "~/trpc/server"; // This is for server components only!
export const MyComponent = () => {
const trpc = useTRPC();
const { data } = useSuspenseQuery(trpc.thread.getById.queryOptions({ id }));
};
Data Fetching Decision Tree
Mutation Patterns
This section covers how to perform data modifications (Create, Update, Delete) using tRPC mutations with React Query.
The Mutation Flow
Pattern 1: Simple Mutation with Cache Invalidation
Use when creating new items where position/sorting is determined by the server:
// apps/web/src/app/(authed)/dashboard/_components/add-thread-modal.tsx
"use client";
import { useRouter } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Controller, useForm } from "react-hook-form";
import { toast } from "@opengovsg/oui";
import { useTRPC } from "~/trpc/react";
import { createThreadInputSchema } from "~/validators/thread";
export const AddThreadModal = () => {
// Step 1: Setup form with Zod validation
const { control, handleSubmit } = useForm({
resolver: zodResolver(createThreadInputSchema),
defaultValues: {
title: "",
content: "",
},
});
const router = useRouter();
const queryClient = useQueryClient();
const trpc = useTRPC();
// Step 2: Setup mutation with cache invalidation
const createThreadMutation = useMutation(
trpc.thread.create.mutationOptions({
onSuccess: ({ id }) => {
// Show success feedback
toast.success("Thread created successfully");
// Invalidate the threads list to refetch with new item
void queryClient.invalidateQueries({
queryKey: trpc.thread.getAll.infiniteQueryKey(),
});
// Navigate to the new thread
router.push(`/threads/${id}`);
},
// onError is handled globally in query-client.ts
})
);
// Step 3: Handle form submission
return (
<form
onSubmit={handleSubmit((values) => createThreadMutation.mutate(values))}
>
{/* Form fields with Controller */}
<Controller
name="title"
control={control}
render={({ field, fieldState: { error } }) => (
<TextField
isInvalid={!!error}
errorMessage={error?.message}
label="Title"
{...field}
/>
)}
/>
<Button type="submit" isPending={createThreadMutation.isPending}>
Submit
</Button>
</form>
);
};
Pattern 2: Optimistic Cache Update (No Refetch)
Use when you want instant UI feedback and the mutation response contains all needed data:
// apps/web/src/app/(authed)/threads/[threadId]/_components/add-comment-modal.tsx
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "~/trpc/react";
export const AddCommentModal = ({ threadId }: { threadId: string }) => {
const queryClient = useQueryClient();
const trpc = useTRPC();
const createCommentMutation = useMutation(
trpc.comment.create.mutationOptions({
onSuccess: (newComment) => {
// Strategy 1: Direct cache update for comments list
// More efficient than invalidateQueries - no network request!
queryClient.setQueryData(
trpc.comment.getCommentsByThreadId.infiniteQueryKey({
threadId,
}),
(oldData) => {
if (!oldData) return oldData;
// Add new comment to the FIRST page (newest first)
const updatedPages = oldData.pages.map((page, index) => {
if (index === 0) {
return {
...page,
comments: [newComment, ...page.comments],
};
}
return page;
});
return {
...oldData,
pages: updatedPages,
};
}
);
toast.success("Comment added successfully");
// Strategy 2: Update related data (thread's comment count)
queryClient.setQueryData(
trpc.thread.getById.queryKey({ id: threadId }),
(oldThreadDetails) => {
if (!oldThreadDetails) return oldThreadDetails;
return {
...oldThreadDetails,
_count: {
comments: oldThreadDetails._count.comments + 1,
},
};
}
);
// Strategy 3: Invalidate unrelated queries where manual update is complex
// The thread list shows comment counts - easier to just refetch
void queryClient.invalidateQueries({
queryKey: trpc.thread.getAll.infiniteQueryKey(),
});
},
})
);
return (
<form
onSubmit={handleSubmit((values) =>
createCommentMutation.mutate(
{ threadId, content: values.content },
{
// Per-call callbacks for UI actions
onSuccess: () => {
reset(); // Clear form
onClose(); // Close modal
},
}
)
)}
>
{/* Form content */}
</form>
);
};
Pattern 3: Mutation with Per-Call Callbacks
You can provide callbacks at both definition time and call time:
// Definition-time callbacks: Business logic
const mutation = useMutation(
trpc.thread.create.mutationOptions({
onSuccess: (data) => {
// Always runs: cache updates, toasts
void queryClient.invalidateQueries({
queryKey: trpc.thread.getAll.infiniteQueryKey(),
});
toast.success("Created!");
},
})
);
// Call-time callbacks: UI-specific logic
mutation.mutate(values, {
onSuccess: () => {
// Runs after definition-time callback
reset(); // Clear form
onClose(); // Close modal
router.push(); // Navigate
},
});
Query Keys Reference
tRPC generates type-safe query keys. Use them for cache operations:
// For regular queries
trpc.thread.getById.queryKey({ id: threadId });
// → ['thread', 'getById', { id: 'abc123' }]
// For infinite queries
trpc.thread.getAll.infiniteQueryKey();
// → ['thread', 'getAll', { type: 'infinite' }]
trpc.comment.getCommentsByThreadId.infiniteQueryKey({ threadId });
// → ['comment', 'getCommentsByThreadId', { threadId: 'abc123', type: 'infinite' }]
Cache Update vs Invalidation Decision Matrix
| Scenario | Approach | Reason |
|---|---|---|
| Add item to list | setQueryData | Instant feedback, response has all data |
| Update single item | setQueryData | Response contains updated item |
| Delete item | setQueryData | Just filter out the item |
| Update affects sort order | invalidateQueries | Server determines new position |
| Update affects pagination | invalidateQueries | Cursor positions may change |
| Update affects counts elsewhere | Both | Direct update where possible, invalidate rest |
Handling Loading States
const mutation = useMutation(trpc.thread.create.mutationOptions({...}))
// In JSX:
<Button
type="submit"
isPending={mutation.isPending} // Disables button, shows spinner
>
{mutation.isPending ? 'Creating...' : 'Create Thread'}
</Button>
// Also available:
mutation.isIdle // Not yet called
mutation.isPending // Currently running
mutation.isSuccess // Completed successfully
mutation.isError // Failed
mutation.data // Success response
mutation.error // Error object
Database Layer Patterns
Select Objects
Define what fields to select in a centralized location:
// apps/web/src/server/modules/thread/thread.select.ts
import type { Prisma } from "@acme/db/client";
export const defaultThreadSelect = {
id: true,
author: {
select: {
name: true,
email: true,
},
},
_count: {
select: {
comments: true,
},
},
updatedAt: true,
title: true,
content: true,
// Explicitly not selecting comments - loaded separately
} satisfies Prisma.ThreadSelect;
Benefits:
- Consistent data shape across queries
- Prevents over-fetching
- Single place to update when schema changes
Service Layer Pattern
Keep database operations in service files, not routers:
// apps/web/src/server/modules/thread/thread.service.ts
export const createThread = async ({
title,
content,
authorId,
}: {
title: string;
content: string;
authorId: string;
}) => {
const thread = await db.thread.create({
data: {
title,
content,
authorId,
},
});
return thread;
};
Benefits:
- Testable business logic
- Reusable across different entry points
- Clear separation of concerns
Pagination Implementation
Use the "fetch N+1" pattern to detect if more pages exist:
// apps/web/src/server/modules/thread/thread.service.ts
export const getAllThreadsWithPagination = async ({
page = 1,
limit = 5,
}: {
page: number | undefined;
limit: number | undefined;
}) => {
const threads = await db.thread.findMany({
take: limit + 1, // Fetch one extra to check for next page
skip: (page - 1) * limit,
select: defaultThreadSelect,
orderBy: { createdAt: "desc" },
});
let nextCursor: number | undefined = undefined;
if (threads.length > limit) {
threads.pop(); // Remove the extra item
nextCursor = page + 1;
}
return { threads, nextCursor };
};
Type Safety
End-to-End Type Safety
This codebase achieves full type safety from database to UI:
Router Output Types
Export and reuse router output types in components:
// apps/web/src/trpc/types.ts
import type { inferRouterOutputs } from "@trpc/server";
import type { AppRouter } from "~/server/api/root";
export type RouterOutputs = inferRouterOutputs<AppRouter>;
// apps/web/src/app/(authed)/_components/thread-card.tsx
import type { RouterOutputs } from "~/trpc/types";
interface ThreadCardProps {
thread: RouterOutputs["thread"]["getAll"]["threads"][number];
}
Using satisfies for Select Objects
Use TypeScript's satisfies to ensure select objects match Prisma types:
export const defaultThreadSelect = {
// ...fields
} satisfies Prisma.ThreadSelect;
Error Handling
Multi-Layer Error Handling Strategy
The lab repository codebase implements error handling at multiple layers:
┌─────────────────────────────────────────────────────────────┐
│ Layer 1: Global Mutation Handler │
│ (query-client.ts - catches all mutations) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Layer 2: RSC Error Handler │
│ (server.tsx - handles server component errors) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Layer 3: Route-Level Handlers │
│ (not-found.tsx, error.tsx pages) │
└─────────────────────────────────────────────────────────────┘
Layer 1: Global Mutation Error Handler
Handle common error types globally to avoid repetitive error handling:
// apps/web/src/trpc/query-client.ts
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
mutations: {
retry: false,
onError: (error) => {
console.error(">>> Error in mutation", error);
if (isTRPCClientError(error)) {
const result = trpcHandleableErrorCodeSchema.safeParse(error);
if (result.success) {
const code = result.data.data.code;
if (code === "FORBIDDEN") {
return toast.error(
"You are not allowed to perform this action."
);
}
if (code === "UNAUTHORIZED") {
window.location.href = "/sign-in";
return;
}
return toast.error("The requested resource was not found.");
}
}
// Default fallback
toast.error("An unexpected error occurred. Please try again later.");
},
},
},
});
Layer 2: RSC Error Handler
Handle errors from React Server Components:
// apps/web/src/trpc/server.tsx
export const createCaller = async () =>
callerFactory(await createContext(), {
onError: ({ error, ctx }) => {
switch (error.code) {
case "NOT_FOUND":
return notFound(); // Triggers not-found.tsx
case "UNAUTHORIZED":
ctx?.session.destroy();
return redirect("/sign-in");
case "FORBIDDEN":
return forbidden(); // Triggers forbidden.tsx
default:
console.error(">>> tRPC Error in RSC caller", error);
}
},
});
Layer 3: Route-Level Not Found Pages
Create custom not-found pages for better UX:
// apps/web/src/app/(authed)/threads/[threadId]/not-found.tsx
import { NotFoundCard } from "~/app/_components/errors/not-found-card";
export default function ThreadNotFoundPage() {
return (
<NotFoundCard
title="Thread Not Found"
message="The thread you are looking for does not exist or has been deleted."
/>
);
}
Error Type Validation
Use Zod to safely parse error codes:
// apps/web/src/validators/trpc.ts
export const trpcHandleableErrorCodeSchema = z.object({
data: z.object({
code: z.enum(["FORBIDDEN", "UNAUTHORIZED", "NOT_FOUND"]),
}),
});
Client-Side Not Found Handling
Handle not found in client components using notFound():
// apps/web/src/app/(authed)/threads/[threadId]/_components/thread-op.tsx
"use client";
import { notFound } from "next/navigation";
import { useSuspenseQuery } from "@tanstack/react-query";
export const ThreadOp = ({ id }: ThreadOpProps) => {
const trpc = useTRPC();
const { data } = useSuspenseQuery(trpc.thread.getById.queryOptions({ id }));
if (!data) {
notFound(); // Triggers the nearest not-found.tsx
}
return <ThreadCard thread={data} />;
};
Best Practices for Error Handling
- Always log errors for debugging, even if you show a generic message to users
- Use specific error codes (UNAUTHORIZED, FORBIDDEN, NOT_FOUND) rather than generic errors
- Clear sessions on auth errors to prevent stale auth state
- Provide actionable error messages that tell users what to do next
- Don't expose internal errors to users - use generic messages for unexpected errors
Pagination
Schema Definition
// apps/web/src/validators/pagination.ts
export const offsetPaginationSchema = z.object({
limit: z.number().min(1).max(100).optional(),
cursor: z.number().optional(), // Named 'cursor' for React Query compatibility
});
Router Usage
// apps/web/src/server/api/routers/thread.ts
getAll: protectedProcedure
.input(offsetPaginationSchema)
.query(async ({ input }) => {
return await getAllThreadsWithPagination({
limit: input.limit,
page: input.cursor,
})
}),
Frontend Infinite Query
const { data, fetchNextPage, isFetching, hasNextPage } = useInfiniteQuery(
trpc.thread.getAll.infiniteQueryOptions(
{},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
)
);
// Render
{
hasNextPage && (
<Button onPress={() => fetchNextPage()} isPending={isFetching}>
Load More
</Button>
);
}
Authentication & Authorization
Protected Procedures
Use protectedProcedure for routes requiring authentication:
// apps/web/src/server/api/trpc.ts
const authMiddleware = t.middleware(({ ctx, next }) => {
if (!ctx.session.userId) {
ctx.session.destroy();
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
session: { ...ctx.session, userId: ctx.session.userId },
},
});
});
export const protectedProcedure = t.procedure
.use(timingMiddleware)
.use(authMiddleware);
Using Auth Context in Mutations
Access the authenticated user in mutations:
create: protectedProcedure
.input(createThreadInputSchema)
.mutation(async ({ input, ctx }) => {
const thread = await createThread({
authorId: ctx.session.userId, // Type-safe, guaranteed to exist
title: input.title,
content: input.content,
})
return thread
}),
Summary Checklist
When implementing a new CRUD feature, ensure you:
- Validation: Create Zod schemas in
validators/with user-friendly error messages - Router: Add tRPC procedures with proper input validation and procedure type
- Service: Create service functions for database operations
- Select: Define select objects to control data shape
- Form: Use React Hook Form with zodResolver for client validation
- Cache: Implement appropriate cache invalidation strategy
- Errors: Add route-level error pages (not-found.tsx)
- Types: Export and use router output types in components
- Prefetch: Prefetch data in Server Components for better UX