Skip to main content

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:

  1. What was the most challenging aspect of implementing CRUD operations in this module?
  2. How did you ensure the security and integrity of user data during CRUD operations?
  3. What best practices did you follow to make your CRUD implementation scalable and maintainable?
  4. How can you apply the knowledge gained from this module to other areas of your work or future projects?
  5. 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.

warning

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
info

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

warning

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:

  1. Server prefetches data
  2. Data is sent to client and hydrated
  3. 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

FootgunSolutionFile
Shared QueryClient on serverCreate new client per request on server, singleton on browsertrpc/react.tsx
useState with SuspenseUse getQueryClient() function insteadtrpc/react.tsx
Immediate refetch after hydrationSet staleTime > 0trpc/query-client.ts
Server/Client data ownership confusionOnly prefetch in Server Components, render in Client ComponentsPage components
Pending queries not streamedshouldDehydrateQuery includes pendingtrpc/query-client.ts
Non-serializable dataUse SuperJSON for serialize/deserializetrpc/query-client.ts
Request-scoped client not scopedWrap 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

ScenarioStrategyWhy
Creating new itemInvalidate list queriesPosition in list may depend on sorting
Updating single itemDirect cache updateResponse contains updated data
Deleting itemDirect cache update + invalidateRemove from cache, refresh counts
Complex relationshipsInvalidate affected queriesEasier to maintain
Paginated listsInvalidateRecalculating 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>
);
};

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?

MethodReturns DataPopulates CacheUse Case
fetchQuery✅ Yes✅ YesWhen you need to validate/use data
prefetch❌ No✅ YesWhen you just want data in cache

Pattern 3: Client-Side Single Item Query with Suspense

warning

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: data can be undefined, requires loading state handling
  • useSuspenseQuery: data is 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

ScenarioApproachReason
Add item to listsetQueryDataInstant feedback, response has all data
Update single itemsetQueryDataResponse contains updated item
Delete itemsetQueryDataJust filter out the item
Update affects sort orderinvalidateQueriesServer determines new position
Update affects paginationinvalidateQueriesCursor positions may change
Update affects counts elsewhereBothDirect 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

  1. Always log errors for debugging, even if you show a generic message to users
  2. Use specific error codes (UNAUTHORIZED, FORBIDDEN, NOT_FOUND) rather than generic errors
  3. Clear sessions on auth errors to prevent stale auth state
  4. Provide actionable error messages that tell users what to do next
  5. 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