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.
Obviously we could isolate the close ticket button somewhere else in the UI so that the form only has two actions, buuut we like the UX of having all actions that effect the state of the ticket tied together in a single place.
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:
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( v.object({ actionId: v.literal('close'), }), v.object({ 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.
I hope you enjoyed
There is a lot more coming...
If you want to get updates when I publish new guides, demos, and more just put your email in below.