TanStack Frontend Patterns
Overview
Implement TanStack Router routes with proper TRPC integration, query prefetching, type inference, and form handling following the project's frontend architecture patterns.
When to Use This Skill
Use this skill when:
-
Creating new routes with TanStack Router
-
Implementing data prefetching in loaders
-
Optimizing route loading performance
-
Building forms with TanStack Form and TRPC
-
Need type-safe TRPC patterns
Core Patterns
- Route Definition with Loader
Standard pattern for route creation with TRPC data prefetching.
Pattern:
export const Route = createFileRoute( "/app/organization/$id/members" )({ component: RouteComponent, loader: async ({ context, params }) => { // Prefetch data for SSR await context.queryClient.prefetchQuery( context.trpc.organization.getById.queryOptions({ id: params.id, }) ); }, });
function RouteComponent() { const { id } = Route.useParams(); // Extract route parameters const trpc = useTRPC();
// Query with Suspense const { data, refetch } = useSuspenseQuery( trpc.organization.getById.queryOptions({ id }) );
// Mutations const updateOrg = useMutation( trpc.organization.update.mutationOptions({ onSuccess: () => refetch(), onError: (error) => console.error(error), }) );
return <div>{/* Component JSX */}</div>; }
Rules:
-
✅ Extract params with Route.useParams()
-
✅ Use useSuspenseQuery with .queryOptions()
-
✅ Use useMutation with .mutationOptions()
-
❌ Never use .useQuery or .useMutation directly from TRPC
See references/router-loader-examples.md for complete route examples.
- Prefetch Patterns & UX Optimization
Critical Rule: Use await in loader ONLY for main content that renders immediately. Use void for secondary/optimization data.
Understanding prefetchQuery vs fetchQuery
-
prefetchQuery : Loads data into cache, returns void , silent errors. Use when you don't need the data immediately in the loader.
-
fetchQuery : Loads data into cache AND returns it, throws errors. Use when you need the data for logic or to return from loader.
Performance Hierarchy (fastest to slowest)
-
void prefetchQuery
-
Fire-and-forget, loader doesn't wait (fastest, but component may suspend)
-
await Promise.all
-
Waits for slowest query in parallel (good when all queries are critical)
-
await sequential - Waits for each query one by one (slowest, avoid)
Pattern - Single Critical Query:
loader: async ({ context, params }) => { // Critical: Main content - await to prevent empty page await context.queryClient.prefetchQuery( context.trpc.organization.getById.queryOptions({ id: params.id }) );
// Secondary: Can load later - void for best performance void context.queryClient.prefetchQuery( context.trpc.organization.getStats.queryOptions({ id: params.id }) ); void context.queryClient.prefetchQuery( context.trpc.integrations.getAll.queryOptions({ orgId: params.id }) ); },
Pattern - Multiple Critical Queries:
loader: async ({ context, params }) => { // All queries critical for initial render // Using Promise.all to fetch in parallel await Promise.all([ context.queryClient.prefetchQuery( context.trpc.organization.getById.queryOptions({ id: params.id }) ), context.queryClient.prefetchQuery( context.trpc.members.getByOrgId.queryOptions({ orgId: params.id }) ), context.queryClient.prefetchQuery( context.trpc.permissions.getAll.queryOptions({ orgId: params.id }) ), ]);
// Secondary data - void for optimization void context.queryClient.prefetchQuery( context.trpc.analytics.getOrgStats.queryOptions({ id: params.id }) ); },
Pattern - Using fetchQuery:
loader: async ({ context }) => { // Need data in loader for logic or return value const orgsWithProjects = await context.queryClient.fetchQuery( context.trpc.organization.getOrganizationsDetails.queryOptions() );
return { orgsWithProjects }; },
When to use await vs void vs Promise.all :
-
await single query: 1 critical query needed for main content
-
await Promise.all : Multiple critical queries needed for main content (faster than sequential)
-
void : Secondary/optional data (breadcrumbs, stats, analytics) - fastest but component may suspend
When to use prefetchQuery vs fetchQuery :
-
prefetchQuery : Cache data for components, don't need result in loader (most common)
-
fetchQuery : Need data in loader for logic/return value
See references/prefetch-patterns.md for comprehensive prefetch examples and performance analysis.
⚠️ CRITICAL: Suspense Boundary Requirements
When using void prefetchQuery() in loader + useSuspenseQuery() in component, the component MUST be wrapped in <Suspense> !
Why? void prefetchQuery() is fire-and-forget - the loader doesn't wait for the data. If the data isn't in cache when the component renders, useSuspenseQuery() will suspend. Without a <Suspense> boundary, this causes hydration errors like $R[88] is not a function .
Pattern - Component using void-prefetched data:
// In loader: loader: async ({ context }) => { // Fire-and-forget - data may not be ready void context.queryClient.prefetchQuery( context.trpc.invitations.getPending.queryOptions() ); },
// In component - MUST wrap in Suspense: function ParentComponent() { return ( <div> <MainContent /> {/* ✅ CORRECT - Suspense boundary for void-prefetched data */} <Suspense fallback={null}> <PendingInvitationsModal /> </Suspense> </div> ); }
// ❌ WRONG - No Suspense boundary function ParentComponent() { return ( <div> <MainContent /> <PendingInvitationsModal /> {/* Will cause hydration error! */} </div> ); }
Decision Table:
Loader Pattern Data in cache? Suspense needed?
await prefetchQuery()
✅ Always ❌ No
await fetchQuery()
✅ Always ❌ No
void prefetchQuery()
⚠️ Maybe ✅ YES
Rule: If you use void prefix on prefetch, always wrap the consuming component in <Suspense> .
- TRPC v11 Query Pattern (Critical)
IMPORTANT: This template uses TRPC v11's new TanStack Query integration pattern. This is a fundamental pattern change from older TRPC versions.
The Pattern
TRPC v11 provides factory methods (.queryOptions() , .mutationOptions() ) that return configuration objects for TanStack Query's native hooks:
import { useTRPC } from "@/infrastructure/trpc/react"; import { useQuery, useMutation, useSuspenseQuery } from "@tanstack/react-query";
function MyComponent() { const trpc = useTRPC();
// ✅ CORRECT - v11 pattern with factory methods const { data } = useQuery(trpc.organization.getById.queryOptions({ id: "123" }));
const { data: suspenseData } = useSuspenseQuery( trpc.organization.getById.queryOptions({ id: "123" }), );
const updateOrg = useMutation( trpc.organization.update.mutationOptions({ onSuccess: () => console.log("Success"), }), );
// ❌ WRONG - Old pattern (doesn't exist in v11) const { data } = trpc.organization.getById.useQuery({ id: "123", }); const updateOrg = trpc.organization.update.useMutation(); }
Why This Pattern?
Benefits:
-
Better Type Safety - Factory methods ensure TypeScript can properly infer all types
-
TanStack Query Alignment - Uses native TanStack hooks, making docs and community solutions directly applicable
-
More Flexible - Can use any TanStack Query hook (useQuery, useSuspenseQuery, useInfiniteQuery, etc.)
-
Prefetching Support - Same .queryOptions() works in loaders and components
-
Easier Migration - Aligns with TanStack Query's evolution
Factory Methods Available:
-
.queryOptions(input)
-
Returns query configuration for useQuery/useSuspenseQuery/prefetchQuery
-
.mutationOptions(options)
-
Returns mutation configuration for useMutation
-
.queryKey(input?)
-
Returns query key for cache invalidation
Common Patterns
Basic Query:
const { data, isLoading, error } = useQuery( trpc.project.getById.queryOptions({ projectId: "123" }), );
Suspense Query:
const { data } = useSuspenseQuery(trpc.project.getById.queryOptions({ projectId: "123" }));
Mutation with Options:
const createProject = useMutation( trpc.project.create.mutationOptions({ onSuccess: (data) => { toast.success("Project created!"); }, onError: (error) => { toast.error(error.message); }, }), );
Prefetch in Loader:
loader: async ({ context, params }) => { await context.queryClient.prefetchQuery( context.trpc.project.getById.queryOptions({ projectId: params.id }) ); },
Cache Invalidation:
const queryClient = useQueryClient();
// Invalidate all queries for a router await queryClient.invalidateQueries({ queryKey: trpc.organization.queryKey(), });
// Invalidate specific procedure await queryClient.invalidateQueries({ queryKey: trpc.organization.getById.queryKey({ id: "123", }), });
Rules
-
✅ Use useQuery() from @tanstack/react-query with .queryOptions()
-
✅ Use useSuspenseQuery() from @tanstack/react-query with .queryOptions()
-
✅ Use useMutation() from @tanstack/react-query with .mutationOptions()
-
✅ Use same .queryOptions() in loaders and components
-
❌ Never try to call .useQuery() or .useMutation() directly on TRPC procedures (they don't exist in v11)
-
❌ Don't use old TRPC v10 patterns from outdated examples
- Type Inference from TRPC
Always use RouterInputs and RouterOutputs for type inference instead of creating manual types.
Pattern:
import type { RouterOutputs, RouterInputs } from "@/infrastructure/trpc/router";
type SessionData = RouterOutputs["adminAuthSessions"]["listTokens"]["sessions"][0]; type CreateUserInput = RouterInputs["users"]["create"];
function MyComponent() { const [session, setSession] = useState<SessionData | null>(null); // Implementation }
Rules:
-
✅ Use RouterOutputs["routerName"]["procedureName"] for response types
-
✅ Use RouterInputs["routerName"]["procedureName"] for input types
-
✅ Import common types from @project/common
-
✅ Use branded session types (AuthSessionId , McpSessionId , ClientSessionId )
-
❌ Never create manual types that duplicate TRPC response structure
- Form Handling
Always use useAppForm from @/shared/forms/form-context instead of raw TanStack Form.
Pattern:
import { useAppForm } from "@/shared/forms/form-context"; import { FormInput, FormTextarea, FormCheckbox, } from "@/shared/forms";
type Props = { onSubmit: (data: FormData) => void; };
export function MyForm({ onSubmit }: Props) { const form = useAppForm({ defaultValues: { name: "", email: "", subscribe: false, }, onSubmit: async (values) => { await onSubmit(values); }, });
return ( <form onSubmit={form.handleSubmit}> <FormInput field="name" label="Name" form={form} /> <FormInput field="email" label="Email" type="email" form={form} /> <FormCheckbox field="subscribe" label="Subscribe" form={form} /> <button type="submit">Submit</button> </form> ); }
Rules:
-
✅ Use useAppForm from @/shared/forms/form-context
-
✅ Use form components (FormInput , FormTextarea , FormCheckbox )
-
✅ Pass form and field props to form components
-
❌ Don't use raw TanStack Form hooks
See references/form-patterns.md for complete form examples with validation.
- Component Best Practices
Props Naming:
// ✅ Good - Standard Props naming type Props = { isOpen: boolean; onClose: () => void; userName: string; };
export function DeleteMemberModal({ isOpen, onClose, userName }: Props) { // Implementation }
// ❌ Bad - Component-specific props naming type DeleteMemberModalProps = { /* ... */ };
Import Rules:
-
✅ Always use absolute imports (@/path/to/module )
-
✅ Use type instead of interface unless extending
-
✅ Import types from @project/common for shared types
TRPC Cache Invalidation:
const queryClient = useQueryClient();
// ✅ Good - Using queryKey helper await queryClient.invalidateQueries({ queryKey: trpc.organization.queryKey(), });
// Also valid - specific procedure await queryClient.invalidateQueries({ queryKey: trpc.organization.getById.queryKey(), });
Resources
references/
-
router-loader-examples.md
-
Complete route definition examples
-
prefetch-patterns.md
-
Performance optimization and prefetch strategies
-
form-patterns.md
-
Form handling with validation examples
-
type-inference.md
-
TRPC type inference patterns and examples