🐢

Next.js + Conform + shadcn/ui で確認画面付きのフォームを作る

2025/01/05に公開

これは何?

  • タイトルの技術で確認画面付きのフォームを作った備忘録です
  • 環境
    • Next.js 15.1.2(App Router)
    • React 19.0.0
    • Conform 1.2.2
    • shadcn/ui 2.1.8

動作確認

正常ケース


入力画面 => Confirm => 確認画面 => Submit => サーバー処理 => Thanksページ の順に遷移します。入力画面のパスは /use-conform/create、確認画面のパスは/use-conform/create/confirmとしています。

フロントバリデーションエラーのケース


画面入力でバリデーションエラーになった動きです。
サブミットボタンの押下、および入力が離れたらバリデーションをします。
再バリデーションが走るのは再入力中のときです。

サーバーアクションでのバリデーションエラーのケース


確認画面上でDOM操作し、emailを書き換えて送信します。
サーバーアクションに実装したバリデーションでエラーを補足し、入力画面に戻ってバリデーションエラーを表示します。

確認画面でキャンセルしたケース


確認画面に表示された入力データが、入力画面に戻っても維持されます。
ちなみに、入力画面でキャンセルした場合は"/"に戻りますが記事の内容と関係ないので省略します。

デモ環境

https://nextjs-dashboard-azure-pi-92.vercel.app/use-conform/create
※ 見えなかったらご連絡ください。

仕様

  • クライアントとサーバーアクションでバリデーションする
  • 確認画面でキャンセルしたときに入力データ保持
  • 送信ボタンのダブルサブミット抑止
  • 画面リロードしたら入力データ/バリデーションエラーをクリア
  • GUIはshadcn/uiにできるだけ任せる(自前であまりスタイルを弄らない)
  • 入力画面と確認画面のURLは別にする

実装方針

  • クライアントとサーバーアクションのバリデーションロジックは共通化する
  • 画面遷移時のデータ維持は、Conformに任せる
  • バリデーションの取り回しは、Conformに任せる
  • バリデーション制御、サーバーアクション制御、データの状態制御はConformに集中させ、画面側のコンポーネントで実施しない
  • 送信ボタンの状態制御はReactuseFormStatusで実施する
  • Conformに制御を集中させるため、サーバーアクションの実行はフォームタグのactionプロパティではなく、ReactstartTransitionスタンドアロン関数で実施する

コード

zodスキーマ

import { z } from "zod";

export const schema = z.object({
  email: z.string().email(),
  name: z.string(),
});

データの状態管理/サーバーアクション管理をするコンポーネント

"use client";

import { ReactNode, startTransition, useActionState } from "react";
import {
  FormProvider as ConformFormProvider,
  useForm,
} from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { schema } from "@/app/lib/use-conform/schema";
import { getZodConstraint } from "@conform-to/zod";
import { useRouter } from "next/navigation";
import { createData } from "@/app/lib/use-conform/action";

export default function FormProvider({ children }: { children: ReactNode }) {
  // isPending使わないので取得しない。useFormStatusで制御する
  const [lastResult, formAction] = useActionState(createData, undefined);
  const router = useRouter();
  const [form] = useForm({
    lastResult, // サーバーアクションの状態と同期させる
    constraint: getZodConstraint(schema), // アクセシビリティ対応
    defaultValue: {
      email: "",
      name: "",
    },
    onValidate({ formData }) {
      return parseWithZod(formData, { schema: schema });
    },
    shouldValidate: "onBlur", // 初回バリデーションは入力が離れたとき
    shouldRevalidate: "onInput", // 再バリデーションは再入力中
    onSubmit(event, { formData }) {
      event.preventDefault(); 

      switch (formData.get("intent")) {
        case "confirm":
          router.push("/use-conform/create/confirm");
          break;
        case "submit":
          // formタグのactionプロパティにサーバーアクション関数を指定する方法の代替手段
          startTransition(() => {
            formAction(formData);
          });
          break;
        default:
          break;
      }
    },
  });

  return (
    // useFormで設定した状態管理を子コンポーネントに伝搬するためのプロバイダ
    <ConformFormProvider context={form.context}>{children}</ConformFormProvider>
  );
}

intentによるonSubmitの制御はConformのドキュメントを参考にしました。

レイアウトファイル

import { ReactNode } from "react";
import FormProvider from "../../ui/use-conform/create/form-provider";

export default async function Layout({ children }: { children: ReactNode }) {
  return <FormProvider>{children}</FormProvider>;
}

入力画面と確認画面のコンポーネントに適用するレイアウトファイルです。上記で作成したプロバイダを親にして、それぞれの画面コンポーネントを描画することで、子の画面コンポーネントからプロバイダ経由でフォームの状態を取得し、画面をまたいだデータ保持を実現します。

レイアウトを含む画面ファイルのフォルダ構成は以下の通りです。URIに含まれない(create)フォルダを作って、編集フォームやログインフォームを作るときに実装しやすくします。

app/use-conform
└── (create)
    ├── create
    │   ├── confirm
    │   │   └── page.tsx
    │   └── page.tsx
    └── layout.tsx 

入力画面のフォーム

"use client";

import { useFormMetadata, useField } from "@conform-to/react";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import Link from "next/link";

export default function Form() {
  // プロバイダにアクセスしてフォームの状態を取得
  const form = useFormMetadata();
  // プロバイダにアクセスしてフォームの入力データを取得
  const [email] = useField<string>("email");
  const [name] = useField<string>("name");

  return (
    // ConformでバリデーションするのでnoValidateでDOMバリデーションを抑止
    <form id={form.id} onSubmit={form.onSubmit} noValidate>
      <Card className="w-[350px]">
        <CardHeader>
          <CardTitle>Form with Conform</CardTitle>
          <CardDescription>Please input your info</CardDescription>
        </CardHeader>
        <CardContent>
          <div className="grid w-full items-center gap-4">
            <div className="flex flex-col space-y-1.5">
              <Label htmlFor="email">Email</Label>
              <Input
                type="email"
                key={email.key}
                name={email.name}
                defaultValue={email.value || email.initialValue}
                placeholder="sample@example.com"
              />
              <div className="text-red-500">{email.errors}</div>
            </div>
            <div className="flex flex-col space-y-1.5">
              <Label htmlFor="name">Name</Label>
              <Input
                type="text"
                key={name.key}
                name={name.name}
                defaultValue={name.value || name.initialValue}
                placeholder="John Doe"
              />
              <div className="text-red-500">{name.errors}</div>
            </div>
          </div>
        </CardContent>
        <CardFooter className="flex justify-between">
          <Link href="/">
            <Button variant="outline">Cancel</Button>
          </Link>
          <Button type="submit" name="intent" value="confirm">
            Confirm
          </Button>
        </CardFooter>
      </Card>
    </form>
  );
}

親コンポーネントに定義したプロバイダからフォームのメタデータと入力データを取得して、フォームに渡す作りです。確認画面も同様の作りにして、画面のURLが変わったときにプロバイダで保持したデータを使い回します。

送信ボタンのコンポーネント

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
+    const status = useFormStatus();
+    const isSending = status.pending && props["type"] === "submit";
+    props["children"] = isSending ? "Sending..." : props["children"];
    const Comp = asChild ? Slot : "button";
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
+        disabled={isSending}
      />
    );
  }
);

shadcn/uiButtonコンポーネントuseFormStatusを加えて、ダブルサブミットを抑止します。const isSending = status.pending && props["type"] === "submit";props["type"] === "submit"を条件に加えることで、キャンセルボタンをダブルサブミット抑止の対象から外しました。

確認画面のフォーム

"use client";

import { useField, useFormMetadata } from "@conform-to/react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import Link from "next/link";
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";

export default function Form() {
  const form = useFormMetadata();
  const [email] = useField<string>("email");
  const [name] = useField<string>("name");
  const { replace } = useRouter();

  useEffect(() => {
    // サーバーアクションでバリデーションエラーが出たら入力画面へ遷移。
    // ブラウザに履歴を残したくないのでreplaceで実行する。
    if (form.status === "error") {
      replace("/use-conform/create");
    }
  });

  return (
    <Card className="w-[350px]">
      <CardHeader>
        <CardTitle>Please check your input</CardTitle>
        <CardDescription>please submit if all acceptable</CardDescription>
      </CardHeader>
      <CardContent>
        <ul className="font-bold list-disc list-inside">
          <li>{`${email.name}: ${email.value}`}</li>
          <li>{`${name.name}: ${name.value}`}</li>
        </ul>
      </CardContent>
      <CardFooter>
        <form id={form.id} onSubmit={form.onSubmit} className="flex gap-8">
          <input type="hidden" name={email.name} value={email.value} />
          <input type="hidden" name={name.name} value={name.value} />
          <Link href="/use-conform/create">
            <Button variant="outline">Cancel</Button>
          </Link>
          <Button type="submit" name="intent" value="submit">
            Submit
          </Button>
        </form>
      </CardFooter>
    </Card>
  );
}

画面に表示する入力データと、実際に送信するhiddenのフォームデータには共通のものを使います。

サーバーアクション

"use server";

import { parseWithZod } from "@conform-to/zod";
import { schema } from "./schema";
import { redirect } from "next/navigation";

export async function createData(_prevState: unknown, formData: FormData) {
  const submission = parseWithZod(formData, {
    schema: schema,
  });

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

  // simulate database query
  await new Promise((resolve) => setTimeout(resolve, 1000));

  redirect("/thanks");
}

フロントバリデーションと同様のバリデーションをサーバーアクションでも実行し、バリデーションエラーが出たら結果を返却します。確認画面でエラーを受け取ったら入力画面に遷移してエラーを表示します。

コード解説は以上です。入力画面と確認画面のコンポーネントはフォームをレンダリングしてるだけなので割愛します。

次にやること

  • 入力画面をリロードしたときに入力データを保持する
  • 確認画面にブラウザのアドレスバーからURL直打ちでアクセスされた場合の入力画面リダイレクト
  • フォームへCSRF対策

参考

https://zenn.dev/akfm/articles/server-actions-with-conform
https://zenn.dev/coji/articles/f6519cdfebda0e
https://zenn.dev/fukurou_labo/articles/c06a3ccfb5f345

Discussion