Intro
tRPC and Zod drastically sped up development for a product I've been building called Mangrove and reduced regressions when refactoring code.
This article illustrates how to implement tRPC and query
procedures with Express and React.
- Zod will be discussed in a separate article
- tRPC Mutations will be discussed in a separate article
About Mangrove
TRPC
From the homepage of tRPC's site:
Move Fast and Break Nothing. End-to-end typesafe APIs made easy.
We're going to:
- Connect tRPC to Express
- Connect tRPC to React
- Look at a simple
query
procedure
First let's start with package.json
.
package.json
{
"dependencies": {
"@tanstack/react-query": "^4.29.15",
"@trpc/client": "^10.38.5",
"@trpc/react-query": "^10.38.5",
"@trpc/server": "^10.38.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0",
"zod": "^3.22.2"
}
}
tRPC and Express
From path/to/backend/trpc-router.ts
// From package.json; adapter that wires up `tRPC` to `express`
import * as trpcExpress from '@trpc/server/adapters/express';
// Our data entities
import clients from 'path/to/backend/procedures/clients';
/*
Defines vars you expect to exist in your procedures or middlewares,
like IP Addresses, additional pre-loaded data, etc.
This is just the context definition.
Middlewares are responsible for setting the context vars at runtime.
(`req` and `res` are injected via the adapter)
*/
const createContext = ({ req, res }: CreateExpressContextOptions) => {
return {
req,
res,
user: req.user as null | User,
};
};
// Initialization of tRPC backend, only initialize once
const t = initTRPC.context<typeof createContext>().create();
const router = t.router;
// Default procedure type for unauthenticated requests like `Forgot Password`
export const publicProcedure = t.procedure;
// Skipping the implementation details of `isAuthed` for brevity
// This procedure validates if a user is logged in or issues a 401
export const protectedProcedure = t.procedure.use(isAuthed);
// Defines a top-level router and 2 subrouters
// Defining subrouters helps with organization and frontend call structure
// The frontend will look like `trpc.clients.procedureName.useQuery()|useMutation()`
const trpcRouter = router({
clients: router(clients),
});
// Ensures `express` properly parses `body` params in POST requests to `/trpc`
app.use('/trpc', bodyParser.json());
// POST `/trpc` is now available for API calls
app.use(
'/trpc',
trpcExpress.createExpressMiddleware({
router: trpcRouter,
createContext,
})
);
// Used in types.ts, a file shared by the frontend and backend
// This is CRITICAL for frontend type safety!!
export type TRPCRouter = typeof trpcRouter;
Adding procedures to tRPC router
From path/to/backend/procedures/clients.ts
// I prefer to store each procedure in individual file,
// but these could be defined inline
import { search } from '/path/to/backend/procedures/clients/search';
import { fetch } from '/path/to/backend/procedures/clients/fetch';
// Make these procedures available to the router
export default {
search,
fetch
};
Types
From path/to/shared/types.ts
For smaller projects, I prefer to put all my TS types in a single file that's shared between the frontend
and backend
.
Structure your filesystem and type definitions based on your team's needs and the size of your project.
import type { TRPCRouter } from 'path/to/backend/trpc-router';
export type TRPC = TRPCRouter;
Fetch procedure
From /path/to/backend/procedures/clients/fetch.ts
In the below example, we passed in clientUUID
to trpc.clients.fetch.useQuery({ clientUUID })
.
To understand how those arguments work we're going to take a look at Zod
next, but first let's look at how simple created backend tRPC procedures
is.
// From package.json
import z from 'zod';
// Ensures only logged-in users can request this data
import { protectedProcedure } from 'path/to/backend/trpc-router';
import { Client } from 'path/to/shared/types';
// Context function that inferfaces with our
// db to return a row from the `clients` table
import { fetchClient } from 'path/to/backend/context/clients/fetchClient';
// Our `zod` schema which we will review next
const fetchClientSchema = z.object({
clientUUID: z.string().uuid()
});
/**
* Returns a `client` record from the db, if a match is found
*
* `ctx` contains our Context defined in `path/to/trpc-router`
* `input` contains input validated in the `.input()` function using `zod`
*/
export const fetch = protectedProcedure
.input(fetchClientSchema)
.query(async ({ ctx, input }) => {
const clientUUID = input.clientUUID;
const client = await fetchClient({ clientUUID });
/*
I recommend always returning an object from procedures:
1. It makes refactoring or adding additional response data easier
2. More importantly, tRPC doesn't allow undefined responses
to be returned. This pattern prevents that by always
returning an object
Now, if we get `{client: null | undefined}`,
we won't trip a runtime error on the backend.
*/
return { client } as { client: Client | null | undefined };
});
tRPC and React
This guide expects familiarity with react-query
, specifically how useQuery
and useMutation
work.
If you are unfamiliar with react-query
, refer to React Query's Quickstart, but once you understand the concepts, don't dive too deep, because @trpc/react-query
does the heavy lifting.
From path/to/frontend/trpc.ts
// From package.json
import { createTRPCReact } from '@trpc/react-query';
// Shared frontend/backend file (the bridge providing tight TS definitions)
import type { TRPC } from 'path/to/shared/types';
// What we'll import from .tsx files
export const trpc = createTRPCReact<TRPC>();
Entrypoint
From: path/to/frontend/index.tsx
// From package.json
import { QueryClientProvider } from '@tanstack/react-query';
import * as React from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { Router } from 'path/to/frontend/Router';
import { reactQueryClient } from 'path/to/frontend/react-query-client';
createRoot(document.getElementById('root') as Element)
.render(
<React.StrictMode>
<QueryClientProvider client={reactQueryClient}>
<BrowserRouter>
<Router />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>
);
Router
From path/to/frontend/Router.tsx
// From package.json
import { useState } from 'react';
import { httpBatchLink } from '@trpc/client';
import { QueryClient } from '@tanstack/react-query';
import { trpc } from 'path/to/frontend/trpc';
import { reactQueryClient } from 'path/to/frontend/react-query-client';
export function Router() {
// Allows for batching multiple tRPC requests in a single API request
const [ trpcClient ] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: `${host()}/trpc`,
fetch(url, options) {
return fetch(url, {
...options,
credentials: 'include',
});
},
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={reactQueryClient}>
<YourRouterHere />
</trpc.Provider>
);
}
function host() {
return window.location.host.includes('localhost')
? 'http://localhost:3000'
: window.location.origin;
}
React Query Client
From path/to/frontend/react-query-client
import { QueryClient } from '@tanstack/react-query';
// Boilerplate for react-query so that the QueryClient is shared
// for tRPC and non-tRPC API requests
export const reactQueryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false,
},
},
});
Client list
import { DataGrid } from '@mui/x-data-grid';
import { objectFromQS } 'path/to/formatters';
import { trpc } from 'path/to/frontend/trpc';
/**
* List of clients rendered into Material UI's Datagrid
*/
export default function Clients() {
// Converts `window.location.search` to object {limit: 100, offset: 0}
const filters = objectFromQS();
/*
Voila! 🪄
{
data: [...arrayOfClients],
isLoading: boolean,
refetch: () => void,
...additional
}
*/
const clients = trpc.clients.search.useQuery(filters);
return (
<DataGrid
loading={clients.isLoading}
rows={clients.data?.clients ?? []}
columns={[
{
field: 'first_name',
headerName: 'First name',
},
{
field: 'last_name',
headerName: 'Last name',
}
]}
/>
);
}
Client details
import { useParams } from 'react-router-dom';
import { trpc } from 'path/to/frontend/trpc';
/**
* When a client row is clicked from the datagrid,
* render a detail card with information about that client
*/
export default function Client() {
const { clientUUID } = useParams() as { clientUUID: string; };
const client = trpc.clients.fetch.useQuery({ clientUUID }); // 🪄
return (
<ClientCard
client={client.data?.client}
isLoading={client.isLoading}
/>
);
}
Simple, magical, resilient ❤️