Building a typesafe multi-form action handler in Remix Series - Hooks, proxy and Dispatcher to the rescue

In this series, I build a small Todo application. To begin, I use a combination of Remix, TypeScript, Tailwind CSS, and React. It'll be functional but fragile to change. To address the fragility, I introduce and use Remix Validated Form, a React form library, Zod, a TypeScript-first schema validation framework, and a few helpers to glue it all together. Each post in this series will build upon the last by solving more of the problem and ends with a robust typesafe solution.

📣
This was updated on 11/2024. It includes the latest versions of Remix, TypeScript, Tailwind CSS, React, Remix Validated Form, and Zod.

Posts in this series

  1. The problem (read this first)
  2. Remix validated form & Zod (read this second)
  3. Hooks, proxy and Dispatcher to the rescue (you are here)

If you haven't read part 1 or part 2, I highly recommend you start there.

💡
The source code for the final part is on GitHub, or you can try it out on Stackblitz
🚨
A 'productionised' version of the source code is also available on Github, or you can try it on Stackblitz

This is for all who sometimes think (including me), "Yeah, sure, this is all nice and well. But what about using it in a production application?"

Recap

In the first two posts of the series, you may have noticed that the Todo application code used many "magic" strings. What I mean by "magic" is that these strings are important but aren't Typesafe, and an incorrect change to them or what they represent will break the application at runtime and not build time. For example, if I were to incorrectly type a field name or field value, the application would break at runtime. The same can happen when refactoring the post data model property names without the IDE or one's self updating form field names.

Helpers to the rescue

To rid the application of these "magic" strings, I'll define a few helpers using TypeScript and the Proxy object.


Updating the Todo application

In part 1 and part 2, I made a simple Todo application. However, too many "magic" strings are being used, making the application fragile to change. I'll now add a few helpers to replace these strings in a typesafe way.

Dispatcher

In the loader function, I have a switch statement to dispatch the action based on the value set in the _action property of the form post. And whilst I get type safety for values that aren't defined for the discriminator in the discriminated union, I do not get type safety for not declaring switch cases for all discriminated values.

To address this, I define a dispatcher that will use the type information from Zod to define the correct shape for a dispatch handler object. A correct shape of a dispatch handler is an object that declares handlers for all the values defined for the discriminator in the discriminated union.

The shape is described as follows: For every discriminator value in the discriminated union; the object has a property named by the discriminator value referencing a function with a signature of async (data: T): unknown.

type ZodActionType = Validator<{ _action: string }>

type ExtractZodUnion<T extends ZodActionType> = Extract<
  Awaited<ReturnType<ExtractGeneric<TypeWithGeneric<T>>['validate']>>,
  SuccessResult<unknown>
>['data']

type DispatchActions<T extends { _action: string }> = {
  [P in T['_action']]: (
    data: Extract<T, { _action: P }>,
  ) => Promise<unknown>
}

export type DispatchActionsLookup<T extends ZodActionType> = {
  [P in ExtractZodUnion<T>['_action']]: `${P}`
}

export async function dispatch<Validator extends ZodActionType>(
  data: DataFunctionArgs,
  validator: Validator,
  actions: DispatchActions<ExtractZodUnion<Validator>>,
) {
  const formData = await data.request.formData()
  const result = await validator.validate(formData)

  if (result.error) return validationError(result.error)

  if (!Object.keys(actions).includes(result.data._action))
    throw new Error(`No action handler declared for ${result.data._action}`)

  return (await (actions as any)[result.data._action](result.data)) ?? null
}

Meaning I can replace my previous action handler with the following:

export const action = async (data: DataFunctionArgs) => {
  // Simulate network latency
  await randomDelayBetween(250, 1000)

  return await dispatch(data, validator, {
    reset: async _ => {
      db.populateSample()
    },
    delete: async action => {
      db.patch(action.id, { deleted: true })
    },
    complete: async action => {
      db.patch(action.id, { completed: true })
    },
    upsert: async action => {
      if (action.id) {
        db.patch(action.id, { description: action.description })
      } else {
        db.append({ description: action.description })
      }
    },
  })
}

As you can see, with the dispatcher added, I now supply an object to represent what I used a switch statement for earlier. Additionally, you may have noticed I moved the boilerplate validation logic into the dispatch function. Although it violates the single-responsibility principle (SRP), it fits and behaves nicely while providing less boilerplate code in this action handler, and any future one I may declare, so I allow it.

With this update, I still get a type error if I add a handler for an action that hasn't been declared.

But more importantly, if I forget to add a handler for an action, I now get a type error.

Using the dispatcher over the switch-on-string method yields more typesafety and less boilerplate code.

Hook: useDispatchActions

If I look at the submit button in the upsert form, the dispatch value upsert is a magic string. The issue with using a string here is that there's no type safety over the value, so an incorrect value does not cause a build error.

To fix this, I'll define a helper useDispatchActions that will return a proxy object. The proxy will return the name of an accessed property as a string. For example, console.log(dispatchActions.HelloWorld) will log the value "HelloWorld". This proxy isn't all that helpful by itself, but I'll then define a type to state that the only valid properties on this proxy are the actions defined as discriminated values in a discriminated union.

import type { UnionToIntersection } from 'type-fest'

type MapKeys<T> = T extends any
  ? {
    [key in keyof Required<T>]-?: key
  }
  : never

type PropertyFields<T> = UnionToIntersection<MapKeys<T>>

function createObjectFieldsProxy<T extends object>(): PropertyFields<T> {
  return new Proxy(
    {},
    {
      get(target: T, property: string | symbol): any {
        return property
      },
    },
  ) as PropertyFields<T>
}

Using the createObjectFieldsProxy function above, I now define useDispatchActions as:

export function useDispatchActions<T extends Validator<any>>(
  _validator: T,
): DispatchActionsLookup<T> {
  return createObjectFieldsProxy<T>()
}

Meaning back on the upsert form, I can now replace the string value for _action input with dispatchActions.upsert, as shown below:

const dispatchActions = useDispatchActions(validator)

...

<Button
  className='text-black'
  type='submit'
  name='_action'
  value={dispatchActions.upsert}
  disabled={loadingContext.isLoading}
>
  {edit ? 'Edit' : 'Add'}
</Button>

And, of course, trying to reference a property which isn't a valid discriminated value causes an error.

Hook: useValidatorFields

The last magic string I want to remove is the field name value for all form inputs. To do this, I repeated what I did for the dispatch action magic strings. I'll first define another hook useValidatorFields as follows:

type ValidatorModelType<T extends Validator<any>> = T extends Validator<
  infer TModel
>
  ? TModel
  : never

export const useValidatorFields = <T extends Validator<any>>(_validator: T) =>
  createObjectFieldsProxy<ValidatorModelType<T>>()

As you can see, it's the same as useDispatchActions apart from a little helper to infer the generic argument from the Zod Validator<T> type.

And, exactly like with the useDispatchActions I define a constant value fields to the return of useValidatorFields(validator) . Meaning I can now update all my form input name values to be like so:

<ValidatedForm
  validator={validator}
  onSubmit={() => {
    setTimeout(clearEdit)
  }}
  resetAfterSubmit={true}
  method='post'
>
  <ValidatedHiddenInput name={fields.id} value={edit?.id.toString()} />
  <div className='mt-2 py-3 px-4 grid grid-flow-col auto-cols-[1fr_200px] gap-2 items-start'>
    <ValidatedTextInput
      className='p-2 border'
      label='To-do description'
      placeholder='Todo description'
      name={fields.description}
      value={edit?.description}
      disabled={loadingContext.isLoading}
    />
    <Button
      className='text-black'
      type='submit'
      name={fields._action}
      value={dispatchActions.upsert}
      disabled={loadingContext.isLoading}
    >
      {edit ? 'Edit' : 'Add'}
    </Button>
  </div>
</ValidatedForm>

And when I have an incorrect value for a field name, I get an error.

Room for improvement?

If there's one thing experience has taught me, there's always room for improvement. Whether to continue, often the case comes down to 'is the return on value worth it?' or for better framed for this situation 'is the return on effort worth it?'.

Is the solution completely 100% typesafe? Sadly, no. Unlike the dispatcher, there's nothing forcing the correct number of form inputs to match the post data shape defined using Zod. So if I were to add a field to capture a Todo category, but didn't update the Upsert form, there would be a permanent validation error at runtime. Yuk! But to fix this would involve a large effort and would likely change how forms are described, which would also likely force a move away from the simplicity of defining forms using rudimentary TSX.

But what this solution does have is a heap of built-in helpers to help void the common problems associated with building typesafe multi-forms in Remix. I hope you enjoyed the series.


You can view the source code for this part on GitHub, or you can try it out on Stackblitz

And don't forget, there's a 'productionised' version of the source code is also available on Github, or you can try it on Stackblitz