iTranslated by AI
Building Forms with Confirmation using Remix, Conform, and shadcn/ui
What is this?
Sometimes, you need to create a form with a confirmation dialog like this, right?

This is a record of my trial and error on how to write it cleanly using Remix + Conform in such situations. We will use shadcn/ui for the confirmation dialog UI component.
Finished Demo
You can try it out directly below. Please feel free!
Process Flow
This is the key point. It is executed in two phases: confirmation and execution.
You might think adding a confirmation phase is cumbersome, but it's actually simpler to write that way when you want to handle form validation perfectly, including certain edge cases.
I'll explain the details of this in the section about the hurdles I faced during trial and error later, so let's first look at the implementation code for the two-phase process.
Code Explanation
First, I'll explain the final code that resulted in the demo shown above.
I will explain the various trials and errors (stumbling blocks) encountered along the way later in the article.
zod schema
Define the form data using zod.
import { z } from 'zod'
const schema = z.object({
intent: z.enum(['confirm', 'submit']),
email: z.string().email(),
})
This is used in both the browser-side form and the server action below.
Browser-side Form
This is the route component that includes the form and the confirmation dialog display.
Since the display is also divided into two phases, it switches the display based on whether actionData.shouldConfirm is returned from the action.
import {
Form, useActionData, useNavigation, useRevalidator
} from '@remix-run/react'
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
Button, Input, Label
} from '~/components/ui' // shadcn/ui components
// route component
export default function DemoConformAlert() {
const actionData = useActionData<typeof action>()
const [form, { email }] = useForm({
lastResult: actionData?.result,
constraint: getZodConstraint(schema),
onValidate: ({ formData }) => parseWithZod(formData, { schema }),
})
const navigation = useNavigation()
const { revalidate } = useRevalidator()
return (
<Form
method="POST"
className="grid grid-cols-1 gap-4"
{...getFormProps(form)}
>
<div>
<Label htmlFor={email.id}>Email Address</Label>
<Input {...getInputProps(email, { type: 'email' })} />
<div className="text-sm text-destructive">{email.errors}</div>
</div>
{/* Display the confirmation dialog with the form in a validated state using intent=confirm */}
<Button
type="submit"
name="intent"
value="confirm"
disabled={actionData?.shouldConfirm}
>
Delete
</Button>
{/* Confirmation Dialog */}
<AlertDialog
open={actionData?.shouldConfirm}
onOpenChange={(open) => {
// Since it closes when the cancel button or ESC key is pressed,
// execute revalidate to run the loader again and
// reset lastResult to the initial state.
// The email value is maintained in the Input DOM,
// so it won't disappear even after revalidate.
!open && revalidate()
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure you want to delete?</AlertDialogTitle>
<AlertDialogDescription>{email.value}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Back</AlertDialogCancel>
{/* intent=submit to submit: actual deletion */}
<AlertDialogAction
type="submit"
name="intent"
value="submit"
disabled={navigation.state === 'submitting'}
form={form.id}
>
{navigation.state === 'submitting' ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Form>
)
}
Two type="submit" buttons
The key point is that there are two buttons with type="submit" name="intent" within the form, and the phase in the action (described later) is identified by this value.
intent is a common naming convention in Remix samples and similar projects. Please note that this is different from the Intent Button provided by conform.
Since both are submit buttons, clicking either one will cause conform to validate the form content in onSubmit. Also, pressing Enter within a field triggers onSubmit, so it executes the form validation.
Confirmation dialog display and behavior when canceled
The confirmation dialog uses shadcn/ui's Alert Dialog.
Since the dialog is displayed when the open attribute is present, we show it when shouldConfirm is returned from the Server Action, and hide it otherwise.
Also, shadcn/ui's AlertDialog is designed to close when AlertDialogCancel is clicked or the ESC key is pressed while it's open. This is identified by the onOpenChange handler, and when closed, it performs a revalidate (re-running the loader).
revalidate is a function available via Remix's useRevalidator hook, which allows the route component to re-execute the loader from scratch.
In the diagram from the Remix documentation, this corresponds to restarting from the Loaders.
By executing this, actionData is reset, and the open attribute of the AlertDialog becomes undefined, resulting in the dialog closing.
Server action processing
Here is the action that processes the form when it is submitted to the server. As mentioned, there are two phases.
import { json, type ActionFunctionArgs } from '@vercel/remix'
import { jsonWithSuccess } from 'remix-toast'
export const action = async ({ request }: ActionFunctionArgs) => {
const submission = parseWithZod(await request.formData(), { schema })
if (submission.status !== 'success') {
return json({ result: submission.reply(), shouldConfirm: false })
}
// If submitted with intent=confirm, return so that the confirmation dialog is displayed
if (submission.value.intent === 'confirm') {
return json({ result: submission.reply(), shouldConfirm: true })
}
// If submitted with intent=submit, perform the actual deletion
await setTimeout(1000) // simulate server delay
// Success: set resetForm: true to reset the form
return jsonWithSuccess(
{ result: submission.reply({ resetForm: true }), shouldConfirm: false },
{ message: 'Deleted successfully', description: submission.value.email }, // toast display
)
}
This part is mostly as described in the comments. The key is the shouldConfirm returned in the JSON, which is set to true only when intent=confirm.
By the way, submission.reply, which became available from conform v1, is incredibly useful. By passing this to the useForm on the form side as lastReply, it handles various tasks for you.
In this case, I passed resetForm: true because I wanted to clear the form once the deletion was complete.
The final jsonWithSuccess is a utility function from remix-toast used to display a toast message upon completion.
Stumbling Points and Solutions
Initial Code
When I first wrote it, thinking "maybe it should look something like this," the code was as follows.
export default function DemoConformAlert() {
const [isAlertOpen, setIsAlertOpen] = useState(false)
const lastResult = useActionData<typeof action>()
const [form, { email }] = useForm({
lastResult,
constraint: getZodConstraint(schema),
onValidate: ({ formData }) => parseWithZod(formData, { schema }),
onSubmit: (event, { formData }) => {
const intent = formData.get('intent')
if (intent === 'alert') {
event.preventDefault()
setIsAlertOpen(true)
}
},
shouldValidate: 'onBlur',
})
const navigation = useNavigation()
return (
<Form method="POST" className="grid grid-cols-1 gap-4" {...getFormProps(form)}>
<div>
<Label htmlFor={email.id}>Email Address</Label>
<Input {...getInputProps(email, { type: 'email' })} />
<div className="text-sm text-destructive">{email.errors}</div>
</div>
<Button type="submit" name="intent" value="alert" disabled={navigation.state === 'submitting'}>
{navigation.state === 'submitting' ? 'Deleting...' : 'Delete'}
</Button>
<AlertDialog open={isAlertOpen} onOpenChange={(open) => setIsAlertOpen(open)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this email address</AlertDialogTitle>
<AlertDialogDescription>{email.value}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Back</AlertDialogCancel>
<AlertDialogAction type="submit" name="intent" value="submit" form={form.id}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Form>
)
}
Coming from the experience of building UIs with React during the SPA era, I think I had a preconceived notion that "confirmation dialogs are things that should be handled on the client side."
In the onSubmit handler passed to useForm, when the intent of the form being submitted was alert, I would set the state to show the dialog to true and stop the submission process by calling event.preventDefault(). If the intent was 'submit', it would proceed with the submission normally.
I wrote this referring to the following code from Conform's documentation on Intent Buttons:
import { useForm } from '@conform-to/react';
function Product() {
const [form] = useForm({
onSubmit(event, { formData }) {
event.preventDefault();
switch (formData.get('intent')) {
case 'add-to-cart':
// Add to cart
break;
case 'buy-now':
// Buy now
break;
}
},
});
return (
<form id={form.id}>
<input type="hidden" name="productId" value="rf23g43" />
<button type="submit" name="intent" value="add-to-cart">
Add to Cart
</button>
<button type="submit" name="intent" value="buy-now">
Buy now
</button>
</form>
);
}
A minor issue: although it's an edge case...
With this, everything worked fairly well for the most part. However, there was one specific case where a problem occurred.
It happened during the following operation sequence:
- Click the delete button while the email address is in an invalid state (it stops due to validation error).
- Correct the email address to a valid one in this state and press the Enter key inside the field (it doesn't get validated!).
- The confirmation dialog appears, so you cancel it here.
- Even though the email address is correct, the error message remains visible (What?!).
Here is the capture of that behavior:

Well, to be honest, this is an edge case and doesn't hinder the user's operation, so I thought it might be fine. But still, if I'm doing it, I want to do it right. I wondered how to solve this.
Solution: The creator of Conform suggested a fix
To address the above issue properly, I pushed the code and vented on X (formerly Twitter) like this:
Edmund, the creator of Conform, saw my post and taught me several things.
It's because field validation and form submission handle errors differently and pressing enter in the field skipped field validation (that clears the error automatically instead of waiting for the server).
This is not an issue usually because the action will return the result eventually and clear the error. But in your case, you prevented the form submission as well so it never receives an update from the server.
There maybe something Conform can do better with the way you handled it. But I can suggest you another approach that will work before JS: https://github.com/techtalkjp/techtalk.jp/pull/20/files
In addition to explaining the reason, he even sent me a pull request with the solution. Seriously?!
I immediately tried running the pull request I received, but unfortunately, the behavior was a bit unclear, so I informed him about it.
Thanks for taking the time to create a pull request. I hadn't considered processing each submission with actions. Click operations work as expected, but the dialog disappears immediately when using the enter key. Can't figure out why...
Japanese translation
プルリクエストの作成に時間を割いてくれてありがとう。各投稿をアクションで処理することは考えていませんでした。クリック操作は期待通りに動きますが、エンターキーを使うとダイアログがすぐに消えてしまいます。なぜでしょうか?
Then this time, he provided a solution that even included a StackBlitz link. Wow!
Sorry for a half-baked solution. Handling it with Remix is probably a better choice: https://t.co/R9hX9SOCIU
Japanese translation
中途半端な解決策で申し訳ない。Remixで処理するのがベターな選択でしょう: https://t.co/R9hX9SOCIU
So, referring to that code, it ultimately resulted in a two-phase implementation.
Wow, I'm thrilled! And indeed, it's much cleaner with the two-step action! Thanks to your code, I've also managed to add support for canceling confirmation with the ESC key. Thank you so much!
Japanese translation
わあ、感激!そして確かに、2ステップアクションの方がずっとすっきりしている!あなたのコードのおかげで、ESCキーで確認をキャンセルできるようになりました。本当にありがとう!
Seriously, Edmund is just incredible. I'm so happy!
Conform is great
Because of this follow-up, I'm now a total Conform advocate. Conform is extremely useful, and the creator is a wonderful person. Conform is great, everyone!
You can use it not only with Remix but also with Next.js App Router!

Discussion
remixの流儀といえば当然かもしれないですが、ダイアログの状態をactionで処理するというのは、どこか新鮮な感じがしました!