tRPC & React Patterns: Router Factories

nicklucas

Nick Lucas

Posted on February 26, 2023

tRPC & React Patterns: Router Factories

This post comes in 2 halves:

  1. tRPC Router Factories
  2. Consuming Router Factories in a React application

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*/,
  }),
})
Enter fullscreen mode Exit fullscreen mode

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 where router and publicProcedure 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 abuse use 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(),
})
Enter fullscreen mode Exit fullscreen mode

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
  }),
})
Enter fullscreen mode Exit fullscreen mode

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 />} 
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

In CreateCarPage we see something like: Error: Type '["cars"]' is not assignable to type '["cities"]'

In this case, this happens for two reasons:

  1. 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
  2. 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>
Enter fullscreen mode Exit fullscreen mode

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 />} 
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
nicklucas
Nick Lucas

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