tRPC Design Patterns for React Developers

I wanted to share this free guide with you because understanding tRPC and its design patterns is one of the most valuable and transferable skills you can gain as a React developer today.

These patterns represent how modern, full-stack React apps are architected in 2025. Mastering them sets you apart.

If you’d rather learn by watching, or want to go much deeper into full-stack design, data fetching strategies, caching, routing, and more, check out Advanced Patterns React, that’s where I teach all of this step-by-step, and way beyond.

Inside the course, you’ll build a production-grade app using React 19, React Query, tRPC, TanStack Router, and TypeScript, all inside a real monorepo setup.

You’ll learn the patterns behind optimistic updates, authentication, cache invalidation, and scalable component design, exactly what senior devs use in the wild.

What is tRPC and Why Should You Care?

tRPC is a powerful toolkit for building type-safe APIs using TypeScript, without needing to generate types from OpenAPI or GraphQL schemas. It lets you call backend procedures directly from your React app, and get full end-to-end type safety automatically.

Imagine calling a function from your server as if it were a local function in React , that’s what tRPC enables. You define backend logic using procedures and routers, and then consume them on the frontend with auto-inferred types. This eliminates entire classes of bugs caused by mismatched request and response types.

Here’s what that looks like in practice:

// server
export const appRouter = router({
  hello: publicProcedure.input(z.string()).query(({ input }) => `Hello ${input}`),
});
// client/Greeting.tsx
import { useQuery } from '@tanstack/react-query';
import { useTRPC } from '../utils/trpc';

export function Greeting() {
  const trpc = useTRPC();
  const helloQuery = useQuery(trpc.hello.queryOptions('World')); // fully typed!
  return <p>{helloQuery.data}</p>;
}

Now you're writing a single source of truth for your app's logic , and your TypeScript compiler becomes your safety net.

How tRPC Works Behind the Scenes

The magic of tRPC is in how it wires together your client and server through procedures, routers, and React Query.

  • On the server, you define a router which groups your procedures , think of them like API endpoints.

  • Each procedure handles input (validated with zod) and returns a typed response.

  • On the client, you use useQuery or useMutation to call those procedures like regular React hooks.

  • tRPC automatically infers the types from server to client.

This makes it feel like your frontend and backend are part of the same codebase , because they are.

Setting Up tRPC in a React App

To start using tRPC, you create a trpc.ts file to initialize your client:

export const { TRPCProvider, useTRPC, useTRPCClient } = createTRPCContext<AppRouter>();

Then wrap your app in the TRPC and React Query providers:

import { QueryClient, QueryClientProvider, useQueryClient } from '@tanstack/react-query';
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import { TRPCProvider } from './utils/trpc';

const queryClient = new QueryClient();
const trpcClient = createTRPCClient<AppRouter>({
  links: [httpBatchLink({ url: '/api/trpc' })],
});

export function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
        <Routes />
      </TRPCProvider>
    </QueryClientProvider>
  );
}

Now you're ready to call backend logic directly from your components with full type safety.

Design Patterns You’ll Use Daily

Once you're set up, the real power of tRPC shows up in how you structure your logic. Here are five patterns that help you build robust, scalable applications.

Pattern 1: Loading Data (Queries)

Fetching data is straightforward with useQuery. For example, to load a list of users:

import { useQuery } from '@tanstack/react-query';
import { useTRPC } from '../utils/trpc';

export function Users() {
  const trpc = useTRPC();
  const { data, isLoading } = useQuery(
    trpc.user.getAll.queryOptions(undefined, { enabled: true })
  );
  /* … */
}

Pass any React‑Query options you need (like enabled, staleTime, etc.) straight into queryOptions.

Pattern 2: Creating and Updating (Mutations)

Mutations work like any React Query mutation, but with full type safety. Here's how you'd create a post and then refresh the list:

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '../utils/trpc';

export function CreatePostButton() {
  const trpc = useTRPC();
  const queryClient = useQueryClient();

  const createPost = useMutation(
    trpc.post.create.mutationOptions(),
    {
      onSuccess: () => {
        // invalidate the cached list in a type‑safe way
        queryClient.invalidateQueries({
          queryKey: trpc.post.getAll.queryKey(),
        });
      },
    },
  );

  return <button onClick={() => createPost.mutate({ title: 'New post' })}>Create</button>;
}

This makes your UI stay in sync without manual state juggling.

Pattern 3: Encapsulating Logic with Custom Hooks

To keep your components clean, wrap logic into custom hooks:

// hooks/useCreatePost.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '../utils/trpc';

export const useCreatePost = () => {
  const trpc = useTRPC();
  const queryClient = useQueryClient();
  return useMutation(trpc.post.create.mutationOptions(), {
    onSuccess: () => queryClient.invalidateQueries({
      queryKey: trpc.post.getAll.queryKey(),
    }),
  });
};

Now your component just calls useCreatePost() , and you’ve got a reusable, testable hook.

Pattern 4: Pagination with Infinite Scrolling

If you’re building a feed or a table that loads more items on scroll, useInfiniteQuery is your friend:

import { useInfiniteQuery } from '@tanstack/react-query';
import { useTRPC } from '../utils/trpc';

export function Feed() {
  const trpc = useTRPC();
  const { data, fetchNextPage } = useInfiniteQuery(
    trpc.posts.paginated.infiniteQueryOptions({ limit: 10 }, {
      getNextPageParam: (last) => last.nextCursor,
    }),
  );
  /* … */
}

This pattern lets you load data incrementally and avoid overwhelming the user (or your API).

Pattern 5: Composing Routers for Scale

As your app grows, you’ll want to split your API logic into separate routers. Here’s how that looks:

// posts.ts
export const postRouter = router({
  list: publicProcedure.query(/* … */),
  create: protectedProcedure.input(/* … */),
});

// index.ts
export const appRouter = router({
  post: postRouter,
  user: userRouter,
});

Each domain gets its own file, and you stay organized even as your app scales.

Best Practices for Clean, Scalable tRPC Apps

To wrap it up, here are a few principles to follow:

  • Use Zod for input validation , it’s fast, typesafe, and designed to pair with tRPC.

  • Structure your API with modular routers to separate concerns.

  • Encapsulate common logic inside custom hooks.

  • Use onSuccess, onError, and invalidate() to keep your UI reactive.

  • Don’t forget React Query Devtools for debugging , they’re incredibly helpful.

Want to Go Deeper?

You’ve now got a solid foundation in how tRPC works, and the design patterns that make it scalable and maintainable in real-world React apps.

And also…

In Advanced Patterns React, we go deep into how these concepts fit into a complete architecture, including advanced React Query usage, routing with TanStack Router, full-stack type safety with tRPC, and clean monorepo structuring with TypeScript.