tRPC & React Patterns: Router Factories
Nick Lucas
Posted on February 26, 2023
This post comes in 2 halves:
Preface
Let's say you have a series of tRPC routes that look something like this:
import { router, publicProcedure } from './trpc'
const appRouter = router({
cities: router({
list: publicProcedure./*etc*/,
get: publicProcedure./*etc*/,
update: publicProcedure./*etc*/,
delete: publicProcedure./*etc*/,
}),
cars: router({
list: publicProcedure./*etc*/,
get: publicProcedure./*etc*/,
update: publicProcedure./*etc*/,
delete: publicProcedure./*etc*/,
}),
people: router({
list: publicProcedure./*etc*/,
get: publicProcedure./*etc*/,
update: publicProcedure./*etc*/,
delete: publicProcedure./*etc*/,
}),
})
It's clear that these routers will share a lot of functionality. They'll each operate on a different entity from your ORM or external API, but otherwise the logic and inputs/outputs will be abstractly similar. In the spirit of DRY we don't want to write and test all the endpoints multiple times if we can avoid it.
This is a perfect situation to employ the age old Factory Pattern with tRPC, we want a "Router Factory".
I've personally found this an incredibly useful pattern in my work, where we have a single router factory generating over 20 different CRUD routes against an internal API, plus another factory for a common CSV import-export pattern which we use on numerous entities. It's saved us hours and made changing and testing our application much more trivial. There used to be a big pain-point in React, which is covered towards the end of this post, but I recently contributed some new APIs to tRPC which make the Router Factory pattern far more useful.
Note, this post does assume a certain level of fundamental knowledge about tRPC and React, for instance:
- You understand what the Factory Pattern is in programming
- You know what the
trpc.ts
file in your codebase is, and by extension whererouter
andpublicProcedure
have come from. - You understand how the
AppRouter
types get from your backend to your frontend, and how your frontend creates the tRPC React Client - You understand React, props, and something of React Query
- You have a vague idea of what an ORM is, preferably knowing one quite well, and so can interpret how the
abuseuse of its APIs would apply here
Router Factory basics
A router factory is fundamentally simple. The basic frame looks a little like this:
import { router, publicProcedure } from './trpc'
function createCrudRouter() {
return router({
/* common procedures here */
})
}
const appRouter = router({
cities: createCrudRouter(),
cars: createCrudRouter(),
people: createCrudRouter(),
})
If you're feeling confident you could probably stop reading here and just go use it, though there's one problem we'll come up against later which you might want to jump to, but let's go a step further and make this work properly with this example.
import { router, publicProcedure } from './trpc'
import type { DbEntityName } from './my-orm'
//
// DB Entities
//
// In practice these may be interfaces, classes, or
// Zod types. It's up to you!
// The pattern does work best where they share
// some common type like EntityBase here.
//
interface EntityBase {
id: number
name: string
}
interface CityEntity extends EntityBase {
population: number
areaSquared: number
}
interface CarEntity extends EntityBase {
brand: string
colour: string
}
interface PersonEntity extends EntityBase {
age: number
height: number
}
//
// The CRUD Factory
//
// We define a config type to set the factory up
interface CrudRouterConfig<T extends EntityBase> {
// We provide some way to select an ORM repository
entityName: DbEntityName
// We need an entity validator for procedure inputs
entityType: SomeTypeValidator<T>
// We need a filter validator for procedure inputs
listFilter: SomeFilterFor<T>
}
function createCrudRouter<TEntity extends EntityBase>(
config: CrudRouterConfig<T>
) {
return router({
list: publicProcedure
.input(config.listFilter)
.output(array(config.entityType))
.query(opts => {
const repo = opts.ctx.orm[config.entityName]
return repo.query(opts.input)
}),
get: publicProcedure
.input(JustId)
.output(config.entityType)
.query(opts => {
const repo = opts.ctx.orm[config.entityName]
return repo.fetchById(opts.input.id)
})
update: publicProcedure
.input(config.entityType)
.output(config.entityType)
.mutation(opts => {
const repo = opts.ctx.orm[config.entityName]
return repo.update(opts.input.id, opts.input)
}),
delete: publicProcedure
.input(JustId)
.mutation(opts => {
const repo = opts.ctx.orm[config.entityName]
return repo.delete(opts.input.id)
}),
})
}
//
// The usage
//
const appRouter = router({
cities: createCrudRouter<CityEntity>({
entityName: 'city',
entityType: tCityEntity,
listFilter: tIdNameFilter
}),
cars: createCrudRouter<CarEntity>({
entityName: 'car',
entityType: tCarEntity,
listFilter: tIdNameBrandFilter
}),
people: createCrudRouter<PersonEntity>({
entityName: 'person',
entityType: tPersonEntity,
listFilter: tNameAgeHeightFilter
}),
})
Some of the config variables (tCityEntity
, tIdNameFilter
, etc) in this implementation aren't defined in the example, simply because there are many approaches to input/output types in tRPC, but essentially they'll be anything you could pass to the input/output for validation.
If all you need to do is DRY out your routers, that's essentially it, but there is one extra problem this introduces in tRPC which you might need to solve...
Using Router Factories polymorphically in React
So let's say you use these routers in a React codebase, and also want to DRY out your components to be compatible with all instances of your CrudRouter. You're going to hit a problem:
import { trpc } from "./trpc-client";
import { useForm } from "./your-form-lib";
interface Props {
// we take the type of our cities router, which is
// a little awkward but we hope will work
router: typeof trpc["cities"]
utils: ReturnType<typeof trpc['useContext']>["cities"]
entityFields: ReactNode
}
function CreateForm({ router, utils, entityFields }: Props) {
const form = useForm()
const entityCreator = router.create.useMutation({
onSuccess() {
utils.invalidate()
}
});
return (
<form
onSubmit={
form.handleSubmit(data => {
entityCreator.mutate(data)
})
}
>
{entityFields}
<button type="submit">Create</button>;
</form>
)
}
// Great!
function CreateCityPage() {
const utils = trpc.useContext()
return (
<CreateForm
router={trpc.cities}
utils={utils.cities}
entityFields={<CityFields />}
/>
)
}
function CreateCarPage() {
const utils = trpc.useContext()
return (
<CreateForm
// TypeScript Error!!!!
router={trpc.cars}
utils={utils.cars}
entityFields={<CarFields />}
/>
)
}
In CreateCarPage
we see something like: Error: Type '["cars"]' is not assignable to type '["cities"]'
In this case, this happens for two reasons:
- tRPC's React-Query types encode the full path of the procedure into the type as a string literal. It needs this for internal reasons, but the outcome is that even if the underlying input/output types and procedure names are identical, you can't pass one router to a type demanding another
- In this case a CityEntity does in-fact have some fields which a CarEntity does not, so you can't pass a CarEntity to
typeof trpc["cities"]
anyway
Wouldn't it be nice if we could create an abstract router type which future implementations can be passed to polymorphically? The answer lies in @trpc/react-query/shared
.
First thing we need to do is create our abstract types. This is done next to your Router Factory:
// '@trpc/react-query/shared' exports several types which can be used to generate abstract client types
import {
RouterLike,
UtilsLike
} from '@trpc/react-query/shared';
function createCrudRouter<TEntity extends EntityBase>(
config: CrudRouterConfig<T>
) {
// You've seen all this!
}
// Take the `typeof` the most basic CrudRouter
type CrudRouteType = ReturnType<
typeof createCrudRouter<EntityBase>
>;
// Generate your abstract types
// These must be exported from your API
// exactly like your AppRouter type
export type CrudRouter = RouterLike<CrudRouteType>
export type CrudRouterUtils = UtilsLike<CrudRouteType>
Once this is done we can update our frontend to import these types and use them:
import { trpc, CrudRouter, CrudRouterUtils } from "./trpc-client";
import { useForm } from "./your-form-lib";
interface Props {
router: CrudRouter
utils: CrudRouterUtils
entityFields: ReactNode
}
function CreateForm({ router, utils, entityFields }: Props) {
const form = useForm()
const entityCreator = router.create.useMutation({
onSuccess() {
utils.invalidate()
}
});
return (
<form
onSubmit={
form.handleSubmit(data => {
entityCreator.mutate(data)
})
}
>
{entityFields}
<button type="submit">Create</button>;
</form>
)
}
// Great!
function CreateCityPage() {
const utils = trpc.useContext()
return (
<CreateForm
router={trpc.cities}
utils={utils.cities}
entityFields={<CityFields />}
/>
)
}
// it works!
function CreateCarPage() {
const utils = trpc.useContext()
return (
<CreateForm
router={trpc.cars}
utils={utils.cars}
entityFields={<CarFields />}
/>
)
}
By using the abstract types from our router factory, we can now pass around tRPC routers and utils, and use standard React patterns to invert control of the bespoke aspects of presentation, for instance entityFields
just expects some presentation which renders the relevant inputs onto the form. This vastly increases the re-usability of frontend components for our CRUD routes, and reduces the new surface area that needs testing for each entity.
Posted on February 26, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024