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 <input11 type='checkbox'12 checked={value}13 onChange={async (e) => {14 'use server'15 await db16 .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 schema10 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// caller14await 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!