Next.js + Conform + shadcn/ui で確認画面付きのフォームを作る
これは何?
- タイトルの技術で確認画面付きのフォームを作った備忘録です
- 環境
- 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を書き換えて送信します。
サーバーアクションに実装したバリデーションでエラーを補足し、入力画面に戻ってバリデーションエラーを表示します。
確認画面でキャンセルしたケース
確認画面に表示された入力データが、入力画面に戻っても維持されます。
ちなみに、入力画面でキャンセルした場合は"/"に戻りますが記事の内容と関係ないので省略します。
デモ環境
※ 見えなかったらご連絡ください。
仕様
- クライアントとサーバーアクションでバリデーションする
- 確認画面でキャンセルしたときに入力データ保持
- 送信ボタンのダブルサブミット抑止
- 画面リロードしたら入力データ/バリデーションエラーをクリア
- GUIはshadcn/uiにできるだけ任せる(自前であまりスタイルを弄らない)
- 入力画面と確認画面のURLは別にする
実装方針
- クライアントとサーバーアクションのバリデーションロジックは共通化する
- 画面遷移時のデータ維持は、Conformに任せる
- バリデーションの取り回しは、
Conform
に任せる - バリデーション制御、サーバーアクション制御、データの状態制御は
Conform
に集中させ、画面側のコンポーネントで実施しない - 送信ボタンの状態制御は
React
のuseFormStatusで実施する -
Conform
に制御を集中させるため、サーバーアクションの実行はフォームタグのaction
プロパティではなく、React
のstartTransitionスタンドアロン関数で実施する
コード
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/ui
のButtonコンポーネントに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対策
参考
Discussion