Building a typesafe multi-form action handler in Remix Series - The problem

Building a typesafe multi-form action handler in Remix Series - The problem
Photo by LexScope / Unsplash

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 (you are here)
  2. Remix validated form & Zod
  3. Hooks, proxy and Dispatcher to the rescue
💡
The source code for this part is on GitHub, or you can try it out on Stackblitz

Remix

If you haven't used Remix yet, I strongly recommend you give it a try. It does an excellent job of merging the frontend and backend into a cohesive continuation using the traditional fundamentals that built the web.

There are three main concepts to understand for a remix route (aka page): the Loader, Action, and the React component. You may have already guessed this; the Loader fetches and passes data to the React component. The Action handles form posts to Create/Update/Do a thing etc. And lastly, the React component renders part of the UI, likely using data returned from the Loader or Action. Both the Loader and Action run server-side.

However, the issue is that Remix supports a single Loader and a single Action per route. This isn't a big issue for the Loader and plays naturally into how a loader normally works. However, handling more than one form post for the Action can get a bit messy. For example, to build a simple Todo application, I will need to implement the operations to add, edit, and complete or remove items from the Todo list.

There are two ways to solve the multiple-form action problem. The first way is to use a single Action exposed via a single route that includes logic within the Action handler to understand the different types of form data being posted. The second way is exposing multiple Actions using multi routes. The advantage of a single action is the action handler logic is contained within a single action and is coupled to the route at the expense of being more complicated than the multi-route method. Posting to different routes has the advantage of simplified action handlers at the expense of losing the self-contained component. I prefer a more complex action handler over multiple routes, as keeping a component together allows for easier refactoring and maintenance.


✍🏼
Side note: If you're unsure what I mean by a multi-form action, below is an excellent video by the Remix crew that explains this in more detail.

Building the Todo application

Simple Todo

To demonstrate the multi-form Action problem and how it is built in Remix, I'll create a Todo application (screenshot above) using only Remix, TypeScript, Tailwind CSS, and React.

Loading data (Loader)

As mentioned earlier, the preferred way of loading data in Remix is via the Loader function. The loader runs server-side, and the results are either used directly, as with the case when the component is rendered server-side or sent to the browser for processing and rendering client-side.

Side note: I created a little database and API that runs within the browser to store the data for this demo. It's pretty straightforward and supports all the operations needed for a basic Todo application.

I want to show any Todo item that isn't completed or deleted. To do that, I define a loader that pulls Todos from the database and filters them accordingly.

export const loader = async () => {
  return db.load().filter(i => !i.completed && !i.deleted)
}

Updating data (Action)

Like the Loader, the Action is the preferred way of processing non-GET requests made to a route, and it too runs on the server.

My Todo application has a few distinct actions that can be performed via the user interface: Reset, Delete, Complete and Add/Edit (also known as Upsert). All of them, except Reset, which simply returns the Todo application to its original demo state, are pretty straightforward.

As I'm using one Action handler, I need a way to determine how HTTP Posts should be mapped to the appropriate handler. To facilitate this, I make all forms define a hidden variable _action containing the value of the action. For example, the _action value for deleting a Todo will be delete.

With all this in mind, I lay down my Action handler.

export const action = async ({ request }: ActionFunctionArgs) => {
  // Simulate network latency
  await randomDelayBetween(50, 350)

  const formData = await request.formData()
  const { _action, ...values } = Object.fromEntries(formData.entries())

  switch (_action) {
    case 'reset':
      {
        db.populateSample()
      }
      break
    case 'delete': {
      if (isNaN(Number(values.id)))
        return 'An error occurred because the delete action was not provided a valid ID.'

      db.patch(Number(values.id), { deleted: true })
      break
    }
    case 'complete': {
      if (isNaN(Number(values.id)))
        return 'An error occurred because the complete action was not provided a valid ID.'

      db.patch(Number(values.id), { completed: true })
      break
    }
    case 'upsert': {
      const isEdit = values.id !== '' && !isNaN(Number(values.id))
      if (typeof values.description !== 'string' || values.description === '')
        return `An error occurred because ${
          isEdit ? 'editing' : 'adding'
        } requires a description to be provided`

      if (isEdit) {
        db.patch(Number(values.id), { description: values.description })
      } else {
        db.append({ description: values.description })
      }
      break
    }
    default:
      return `An error occurred because no handler has been added to process ${_action}`
  }
  return null
}

As you can see, I switch on the _action value, do some rudimentary validation, and process the requested action where valid.

Web user interface (Page)

Now that I have a Loader and Action, I need to build the user interface. This is done by exporting a default component, which Remix will use as the primary React component for a given route. If you're new to Remix, you can read more about it here.

The main job of my component is to render the Todos returned by the Loader, add forms to post mutations to the Action handler, and handle the on-screen state of the component.

Side note: I have chosen not to extract a few parts into separate components for the first post in the series to demonstrate the multi-form Action handler as clearly as possible.

type Todo = Awaited<ReturnType<typeof loader>>[number]

export default function Index() {
  const todos = useLoaderData<typeof loader>()
  const actionResult = useActionData<typeof action>()

  const [editTodo, setEditTodo] = useState<Todo>()
  const [formRef, resetForm] = useFormReset()
  const [inputRef, setInputFocus] = useFocus<HTMLInputElement>()

  useEffect(() => {
    setInputFocus()
  }, [editTodo, setInputFocus])

  const clearEdit = useCallback(() => {
    setEditTodo(undefined)
    resetForm()
  }, [setEditTodo, resetForm])

  const loadingContext = useLoadingContext()

  useEffect(() => {
    if (!loadingContext.isLoading) setInputFocus()
  }, [loadingContext.isLoading, setInputFocus])

  const submit = useSubmit()

  return (
    <div
      className={
        'sm:max-w-screen-sm md:max-w-screen-md lg:max-w-screen-lg mx-[auto]'
      }
    >
      {actionResult && (
        <div className={cn('absolute ml-1', 'bg-error px-3 py-1 text-white')}>
          {actionResult}
        </div>
      )}
      <Form method='post' className='grid mb-2'>
        <GlassButton
          type='submit'
          name='_action'
          value='reset'
          className='place-self-end py-1 px-4'
          onClick={clearEdit}
          disabled={loadingContext.isLoading}
        >
          Reset
        </GlassButton>
      </Form>
      <GlassPanel className='relative'>
        <Title aria-label='Simple to-do'>Simple Todo</Title>
        <Loading
          className='absolute right-2 top-5 animate-spin h-5 w-5 mr-3'
          hidden={!loadingContext.isLoading}
        />
        <Panel className='mt-2 px-4' aria-live='polite'>
          {todos.map(todo => (
            <Form replace method='post' key={todo.id}>
              <input type='hidden' name='id' value={todo.id.toString()} />
              <Panel
                border='b'
                className={cn('p-3', 'hover:bg-glass/20', 'grid grid-flow-col')}
              >
                <div
                  aria-label={`To-do entry ${todo.description}`}
                  aria-flowto={`delete-${todo.id}`}
                >
                  {todo.description}
                </div>
                {!editTodo && (
                  <div className='w-30 justify-self-end grid gap-2 grid-flow-col content-center'>
                    <IconButton
                      id={`delete-${todo.id}`}
                      color='Red'
                      type='submit'
                      name='_action'
                      value='delete'
                      disabled={loadingContext.isLoading}
                      aria-label='Delete to-do entry'
                    >
                      <Delete aria-hidden={true} />
                    </IconButton>
                    <IconButton
                      color='Green'
                      onClick={() => setEditTodo(todo)}
                      disabled={loadingContext.isLoading}
                      aria-label='Edit to-do entry'
                    >
                      <Edit aria-hidden={true} />
                    </IconButton>
                    <input
                      type='checkbox'
                      className='ml-2'
                      name='_action'
                      aria-label='Complete to-do entry'
                      value='complete'
                      onChange={e => {
                        submit(e.currentTarget.form)
                      }}
                      disabled={loadingContext.isLoading}
                    />
                  </div>
                )}
              </Panel>
            </Form>
          ))}
        </Panel>
        <Form
          replace
          ref={formRef}
          onSubmit={() => {
            setTimeout(clearEdit)
          }}
          method='post'
        >
          <input type='hidden' name='id' value={editTodo?.id.toString()} />
          <div className='mt-2 py-3 px-4 grid grid-flow-col auto-cols-[1fr_200px] gap-2 items-start'>
            <input
              type='text'
              ref={inputRef}
              className={cn('p-2 border', 'w-full', 'rounded-md', 'text-black')}
              aria-label='To-do description'
              placeholder='Todo description'
              name='description'
              defaultValue={editTodo?.description ?? ''}
              disabled={loadingContext.isLoading}
            />

            <Button
              className='text-black'
              type='submit'
              name='_action'
              value='upsert'
              disabled={loadingContext.isLoading}
            >
              {editTodo ? 'Edit' : 'Add'}
            </Button>
          </div>
        </Form>
      </GlassPanel>
    </div>
  )
}

As you can see, all the forms have the _action value defined. Now, you might have been expecting me to define the values using a <input type='hidden' /> form field, but I chose to use the key/value attribute of a submit button to achieve the same outcome, a handy little trick.

Lights, camera and action!

Simple todo

Next

In my next post, I introduce and use Remix Validated Form, a React form library, and Zod, a TypeScript-first schema validation framework.


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