Will King

How to create one form with many actions in Remix

We have been in the middle of a migration at Crunchy Data from an express app to Remix, and boy have there been plenty of technical tidbits I am excited to write about on here. Last week I had a really interesting one I couldn't wait to write about. So let's do it.

In our app we have an integrated set of support pages. Behind the scenes it is run through Helpscout, but in app we allow teams to create, reply, and close support tickets without needing to jump to an email client.

The interesting implementation I came across appeared as I was migrating our open ticket page.

As you can see the page consists of three core areas.

  • Reply form.

  • Ticket details

  • Message Thread

What we are digging into is the first one.

One form multiple actions

The reply form is unique in that it is responsible for 3 potential actions that can be taken on it.

  • If no message exists you can close the ticket using the secondary button.

  • Once you have typed a message, that secondary button changes allowing you to submit that message as a new reply on the ticket thread and close the ticket at the same time.

  • Or, you can just submit the message and leave the ticket open.

Three very different outcomes that all live within that single form.

Thankfully, the solution for allowing that is a html feature that has been around since..well I am pretty sure forever. So, let's knock out the core solution and then we can dig into some extra ✨ that makes it nicer to work with in Remix and Typescript.

The core solution

This is all you really need to know that makes this whole thing tick.

In html a button is allowed to have a name and a value. This name and value combo is only appended to the form data if it is the button that was used to submit the form.

That's it. That is the solution, and here is what that shakes down to in our Remix codebase:

<Form id="reply-form" className="flex flex-col gap-6">
			<Panel.Title>New Message</Panel.Title>
				className="block text-sm font-medium text-primary"
				How can we help you today?
			<div className="mt-2">
					className="bg-layer-1 shadow-sm focus:border-crunchy block w-full sm:text-sm border rounded-md"
					onChange={e => setMessage(e.target.value)}
			<p className="mt-2 text-sm">
				Please include the time zone for any timestamps. Thanks!
	<Actions fullWidth>
			disabled={transition.state !== 'idle'}
			value={message.length ? 'reply-and-close' : 'close'}
			{message.length ? 'Close and comment' : 'Close ticket'}
			disabled={transition.state !== 'idle'}
			Post reply
				className="ml-2 -mr-1 h-4 w-4"

After that you can handle the submission on your server however you want because you're just working with a formData submission like usual. That being said, let's dig into some extra systems we put in place at Crunchy to deal with the potential states this form can be submitted as.

Unions and Validation

If you have never heard this quote before I am happy to introduce you to it:

Make impossible states impossible

This is a mantra of type driven development where you eliminate states where data should be impossible. Let's look at how they applies here.

We have a single form that looks something like this:

type Submission = { 
  message?: string
  actionId: "reply" | "close" | "reply-and-close"

Anytime you see an option param it’s worth taking a second look at your type and see if you can eliminate it. In our case we know that if our submission is close we will never have a message. We also know that the other two actions should always have a message. How can our type help us?

type Submission =
  | { action: "close" }
  | {
      actionId: "reply" | "reply-and-close"
      message: string

Awesome our type now matches the states we expect to work with. At Crunchy we take it a step further as well and add in runtime validation. Most people are familiar with Zod, we use a similar library @badrap/valita . Here is what the type above looks like translated over and how you can use that to validate your form data in an action.

const SubmissionValidator = v.union(
		actionId: v.literal('close'),
		actionId: v.union(v.literal('reply'), v.literal('reply-and-close')),
		message: v.string(),

export async function action({ context, params }: ActionArgs) {
	const formData = await request.formData()
	const validated = SubmissionValidator.try(formData, { mode: 'strip' })

	if (!validated.ok) {
		return json({
			message: 'Validation failed on your message submission.',
			messageType: 'error',

	const { value } = validated

	if (value.actionId === 'reply') {
		// do stuff
	} else if (value.actionId === 'close') {
		// do stuff
	} else if (value.actionId === 'reply-and-close') {
		// do stuff

It is in fact just a button

When I found out that a button could do this it opened up so many new doors in my mind for simplifying forms across our codebase and I knew I had to share it with you too.

Hope it helps! Reach out if you want to talk about this or any other related stuff over on twitter.

Updates and More

Get updated when new articles, products, or components are released. Also, whatever else I feel like would be fun to send out.