🎏

remix + conform で複数フォームの送信

2024/12/21に公開

はじめに

こんにちは、hirotooooです。

Remixのformライブラリでconformが良いと聞いたので、使ってみたら

  • フォームの送信のやり方
  • Each child in a list should have a unique "key" prop. の発生

など、色々詰付いた部分があるので、備忘録的な感じで最終的にどうしたかについて説明していきます。

最終的な動作:

コード解説

schema

const schema = z.object({
  products: z.array(
    z
      .string({ required_error: "入力は必須です" })
      .min(2, "2文字以上入力してください")
  ),
});

schemaを定義しています。

action

export const action = async ({ request, params }: ActionFunctionArgs) => {
  try {
    const formData = await request.formData();
    const intent = formData.get("intent");

    switch (intent) {
      case "create": {
        console.log("create");
        const index = parseInt(formData.get("index") as string);
        const product = formData.get("product") as string;
        // ...処理
        break;
      }
      case "update": {
        console.log("update");
        const index = parseInt(formData.get("index") as string);
        const product = formData.get("product") as string;
        // ...処理
        break;
      }
      case "delete": {
        console.log("delete");
        const product = formData.get("product");
        // ...処理
        break;
      }
    }
    return new Response("完了", { status: 200 });
  } catch (error) {
    return new Response(`エラー ${error}`, {
      status: 500,
    });
  }
};

ここでは、actionに送信後の処理を書いています。

formの内容にintentを設定し、intentによって処理を分岐しています。

この中で、dbへの登録・削除・更新等を行います。

conform

const submit = useSubmit();

const [form, fields] = useForm({
  defaultValue: sampleData,
  onValidate: ({ formData }) => parseWithZod(formData, { schema }),
  shouldValidate: "onBlur",
  shouldRevalidate: "onInput",
});

const products = fields.products.getFieldList();

formの設定を行います。

  • defaultValue:初期データの設定
  • onValidate:zodからバリデーション項目を設定
  • shouldValidate/shouldRevalidate:バリデーションのタイミングを設定

const products = fields.products.getFieldList(); とすることで、productsでmapが使えるようになります。

useSubmit を使用して、handleでsubmit送信を行うようにしています。

handle

追加

function handleBlurUpdateProducts(e: React.ChangeEvent<HTMLInputElement>) {
  const formElement = e.currentTarget.form;

  if (!formElement) {
    alert("formがnullです。");
    return;
  }

  const formData = new FormData(formElement);
  const submission = parseWithZod(formData, { schema });

  if (submission.status !== "success") {
    return submission.reply();
  }

  const updateProduct = e.currentTarget.value;
  const index = products.findIndex(
    (product) => product.value === updateProduct
  );

  const isNewProduct = index + 1 > sampleData.products.length;

  sampleData.products[index] = updateProduct;
  formData.append("index", index.toString());
  formData.append("product", updateProduct);
  formData.append("intent", isNewProduct ? "create" : "update");

  console.log(`sampleData: ${JSON.stringify(sampleData.products)}`);

  submit(formData, { method: "POST" });
}

追加ようのhandleです。Blurでフォーカスが外されたときに実行されます。

const formElement = e.currentTarget.form;
で、formElementを取得しています。formが存在するか確認するために使用します。

const updateProduct = e.currentTarget.value;
で、変更された値だけを取得しています。

const index = products.findIndex( (product) => product.value === updateProduct );

では、配列の位置を特定しています。この値で、新しい値か更新する値かを判断したり、配列の更新する部分を特定します。

sampleData.products[index] = updateProduct;
は、サンプルデータを更新するためのコードです。

削除

function handleClickDeleteProduct(product: string) {
  const formData = new FormData();
  formData.append("intent", "delete");
  formData.append("product", product);
  sampleData.products = sampleData.products.filter((p) => p !== product);
  submit(formData, { method: "POST" });
}

これは、削除するFormを送信するhandleです。

sampleData.products = sampleData.products.filter((p) => p !== product);
は、サンプルデータを削除するためのコードです。

Form

return (
  <form
    method="POST"
    {...getFormProps(form)}
    className="h-screen w-full grid justify-center items-center"
  >
    ...
  </form>
);

<form method="POST" {...getFormProps(form)}>

の{…getFormProps(form)}でformのプロパティを設定しています。

詳しくは以下のガイドラインをご覧下さい。

Conform / getFormProps

リスト部分

<Card className="col-span-1 space-y-4 lg:col-span-3">
  ...
  <CardContent className="grid gap-2">
    {products.map((product, index) => (
      <div key={product.key}>
        <div className="flex gap-2">
          <Input
            placeholder="入力"
            defaultValue={product.value!}
            onBlur={handleBlurUpdateProducts}
            {...getInputProps(product, { type: "text" })}
            key={product.key}
          />
          {/* ダミーボタン:Enterキーを押したときに、他のボタンのform送信を防止するため。 */}
          <button
            type="submit"
            name="dummy"
            className="hidden"
            disabled
          />
          <button
            onClick={() => handleClickDeleteProduct(product.value!)}
            {...form.remove.getButtonProps({
              name: fields.products.name,
              index: index,
            })}
            className="w-9 h-9 flex cursor-pointer items-center justify-center rounded-full p-2 hover:bg-slate-100"
          >
            <TrashIcon className="text-red-500" />
          </button>
        </div>
        <p className="text-red-500 text-sm">
          {products[index].errors
            ? products[index].errors![0]
            : fields.products.errors
            ? fields.products.errors[0]
            : ""}
        </p>
      </div>
    ))}
  </CardContent>
 ...
</Card>

{products.map((product, index) => (

は、conformで設定していたconst products = fields.products.getFieldList(); を使用しています。

<div key={product.key}> を設定しないと、Key Props Errorが起こるので、注意。

<Input 
  placeholder="入力" 
  defaultValue={product.value!} 
  onBlur={handleBlurUpdateProducts}
  {...getInputProps(product, { type: "text" })} 
  key={product.key}
/>

ここのonBlurでhandleBlurUpdateProducts を呼び出しています。

{...getInputProps(product, { type: "text" })} は、Inputのプロパティを設定しています。

key={product.key} の設定は、Key Props Error防止のためです。

Conform / getInputProps

<button
  onClick={() => handleClickDeleteProduct(product.value!)}
  {...form.remove.getButtonProps({
    name: fields.products.name,
    index: index,
  })}
  className="w-9 h-9 flex cursor-pointer items-center justify-center rounded-full p-2 hover:bg-slate-100"
>
  <TrashIcon className="text-red-500" />
</button>

削除ボタンです。onClickでhandleClickDeleteProduct を呼び出しています。

{...form.remove.getButtonProps({name: fields.products.name, index: index,})}

では、buttonをクリックすると、動的に削除されるプロパティを設定しています。

Conform / インテントボタン

<p className="text-red-500 text-sm">
  {products[index].errors
    ? products[index].errors![0]
    : fields.products.errors
    ? fields.products.errors[0]
    : ""}
</p>

バリデーションエラーを表示する部分です。

リストの追加

 <CardFooter>
  <Button
    variant="outline"
    {...form.insert.getButtonProps({
      name: fields.products.name,
    })}
  >
    + 追加
  </Button>
</CardFooter>

追加するボタンです。

{...form.insert.getButtonProps({name: fields.products.name,})} では、ボタンをクリックするとproductsにフィールドが追加されるプロパティを設定しています。

終わりに

この記事では、Remix + conformを使ったフォーム送信の例を紹介しました。

conformを使えば、動的なフォーム操作ができます。参考になれば幸いです。

Discussion