Michael McGreal

Michael McGreal

Generic Server Action drizzle CRUD components

While working on an Admin dashboard using the new Next.js /app router, I found myself remaking common CRUD components with small variations.

For example, a checkbox that updates some boolean value in some table.

This could lead to a lot of duplicated code, where the only difference is the key name and the model.

A perfect case for TypeScript Generics<T>!

Using Drizzle as source database types, my first attempts looked something like this. Notice the cluttered interface.

1export default function UpdateCheckbox<T>({
2 ...args
3}: {
4 table: T
5 keyName: keyof T & string
6 value: K[T]
7 id: number
8}) {
9 return (
10 <input
11 type='checkbox'
12 checked={value}
13 onChange={async (e) => {
14 'use server'
15 await db
16 .update(table)
17 .set({ [args.keyName]: e.target.value })
18 .where(table.id, args.id)
19 }}
20 />
21 )
22}

But these basic arg typings were not specific enough. For example, the keyName was valid, but typescript could not narrow the value to type boolean.

So I made some conditional utility types to help narrow the type (type-fest helpers were fantastic). But this did not scale well, and was starting to be more work than maintaining duplicate queries. Especially once I started attempting generic components for other column types (beyond booleans).

Finally, I arrived to a solution where the argument types match the types expected by the query builder.

This came with major unexpected benefits of:

  • allows any number of key/value pairs to be updated!
  • accepts any column type(s) – not just booleans – with no extra typing!
    • thus the name 'updateCheckbox' must be more generic too, like 'updateDatabase'

This approach moves the complexity from the server action function body into the caller params – a natural shift, because the callers already know what they want from the database (they just don't have server access, and can't execute the query directly)!

1import { SQL } from 'drizzle-orm'
2import { AnyMySqlTable, MySqlUpdateSetSource } from 'drizzle-orm/mysql-core'
3 
4// server function that updates the database
5export const updateDb = async <TTable extends AnyMySqlTable>({
6 ...args
7}: {
8 table: TTable
9 values: MySqlUpdateSetSource<TTable> // any table defined in drizzle schema
10 where: SQL // any combination of drizzle operators (including raw sql helper!)
11}) => await db.update(args.table).set(args.values).where(args.where)
12 
13// caller
14await updateDb<typeof pizza>({
15 table: pizza,
16 values: {
17 hasPineapple: true,
18 },
19 where: eq(pizza.name, 'hawaiian'),
20})

Beautiful! Here is the same function invoked for a different update need, with entirely different columns, and a more complex filter – no extra typings required!

1await updateDb<typeof supplier>({
2 table: supplier,
3 values: {
4 capacity: 1400,
5 preferred: true,
6 },
7 where: and(gte(supplier.ordersFullfilled, 50), eq(supplier.verified, true)),
8})

(this topic of moving the complexity to the callers reminds me of the superb netflixTechBlog on how a "OneSizeFitsAll" API is not desireable for clients/callers)

This technique is not limited to server actions, and can be used in regualar API routes too!