iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🤼

Building Forms with Confirmation using Remix, Conform, and shadcn/ui

に公開
1

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!

https://www.techtalk.jp/demo/conform/confirm

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.


Remix Fullstack Data Flow

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:

  1. Click the delete button while the email address is in an invalid state (it stops due to validation error).
  2. Correct the email address to a valid one in this state and press the Enter key inside the field (it doesn't get validated!).
  3. The confirmation dialog appears, so you cancel it here.
  4. 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:

https://twitter.com/techtalkjp/status/1761643957765996910

Edmund, the creator of Conform, saw my post and taught me several things.

https://twitter.com/_edmundhung/status/1761669255232147484

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.

https://twitter.com/techtalkjp/status/1761716880317075546

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!

https://twitter.com/techtalkjp/status/1761740446844256530

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.

https://twitter.com/techtalkjp/status/1761754597264339365

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!

https://conform.guide/

Discussion

MelodyclueMelodyclue

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