shadcn/ui×conformでServer Actionsに対応したフォームを作成してみた!
はじめに
今回は、個人学習の一環としてshadcn/uiとconformを組み合わせてServer Actionsに対応したフォームを作成してみました。
実装の流れや問題点を共有していきます!💫
shadcn/uiを採用した理由
まず、Tailwind CSSをベースに開発されている点が挙げられます。
私が普段Tailwind CSSを使用してスタイリングを行なっているので、学習コストをかけずに使用できるのが魅力でした。
また、JavaScriptライジングスター2023で総合ランキング1位を獲得するなど、非常に注目度の高いライブラリであることも選定理由の一つです。
conformを採用した理由
普段使用しているReact Hook FormがServer Actionsに対応していないためです。
それ以上の理由はないです笑
技術スタック
最近React19の安定版がリリースされましたね。
さようならforwardRef!
ライブラリ | バージョン |
---|---|
react | 18 |
next | 15.0.3 |
@conform-to/react | ^1.2.2 |
環境構築
Next.jsは省きます。
conform
下記コマンドを実行するだけです。
npm install @conform-to/react @conform-to/zod --save
shadcn/ui
shadcn/uiは他のライブラリと異なり、npm経由でインストールを行わないません。
代わりにCLIを通じてコードを取得します。
イメージがつきにくいと思うので、環境構築の手順を説明していきます。
- TailwindCSSの環境構築を行う
- 以下コマンドを実行し、質問に答えます。
$ npx shadcn-ui@latest init
1. Would you like to use TypeScript (recommended)? no/yes
TypeScriptを使いたいか?
2. Which style would you like to use? › Default
デフォルトかNew Yorkどちらのスタイルを使いたいか?
3. Which color would you like to use as base color? › Slate
ベースカラーは何にするか?(後から変更不可)
4. Where is your global CSS file? › › app/globals.css
グローバルCSSファイルはどこに置くか?
5. Do you want to use CSS variables for colors? › no / yes
CSS変数で色を設定するか?(後から変更不可)
6. Where is your tailwind.config.js located? › tailwind.config.js
tailwind.config.jsをどこに置くか?
7. Configure the import alias for components: › @/components
コンポーネントをインポートのエイリアスは?
8. Configure the import alias for utils: › @/lib/utils
utilsのimportのエイリアスは?
9. Are you using React Server Components? › no / yes
React Serverのコンポーネントを使うか?
- 使いたいコンポーネントをnpxで実行(コードをコピー)します。
今回はbuttonにします。
$ npx shadcn-ui@latest add button
- 3でコピーしたコンポーネントは
components/ui
(2を実行した時点で勝手にできてる)配下にbutton.tsx
として作成されます。
/components/ui/button.tsx
// components/ui/button.tsx
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
完成系
先に完成したコードを載せて、後ほど解説します。
/schema/index.ts
import { z } from "zod";
export const DATA_SCHEMA = z.object({
name: z.string({ required_error: "Name is required" }),
country: z.string({ required_error: "From is required" }),
});
/actions/index.ts
"use server";
import { DATA_SCHEMA } from "@/schema";
import { parseWithZod } from "@conform-to/zod";
export const serverActions = async (prevState: unknown, formData: FormData) => {
const submission = parseWithZod(formData, {
schema: DATA_SCHEMA,
});
// データが有効でない場合は、エラーをクライアントに返します。
if (submission.status !== "success") {
return submission.reply();
}
// データが有効な場合は、データをデータベースに保存します。
// 今回は省略します。
return submission.reply();
};
/app/page.tsx
"use client";
import { useActionState } from "react";
import { useForm } from "@conform-to/react";
import { serverActions } from "@/actions";
import { parseWithZod } from "@conform-to/zod";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { DATA_SCHEMA } from "@/schema";
export default function Form() {
const [state, formAction] = useActionState(serverActions, undefined);
const [form, fields] = useForm({
lastResult: state,
onValidate({ formData }) {
return parseWithZod(formData, {
schema: DATA_SCHEMA,
});
},
shouldValidate: "onBlur",
shouldRevalidate: "onInput",
});
return (
<form
id={form.id}
onSubmit={form.onSubmit}
action={formAction}
noValidate
className="w-[300px] m-10"
>
<div>
<Label htmlFor={fields.name.id}>Name:</Label>
<Input
key={fields.name.key}
name={fields.name.name}
defaultValue={fields.name.initialValue}
/>
{fields.name.errors && <p className="text-red">{fields.name.errors}</p>}
</div>
<div>
<Label htmlFor={fields.country.id}>country:</Label>
<Select
key={fields.country.key}
name={fields.country.name}
defaultValue={fields.country.initialValue}
onValueChange={(value) => {
form.update({
name: fields.country.name,
value,
});
}}
>
<SelectTrigger>
<SelectValue placeholder="Choose country" />
</SelectTrigger>
<SelectContent>
{["USA", "JAPAN", "China"].map((i, index) => (
<SelectItem key={index} value={i}>
{i}
</SelectItem>
))}
</SelectContent>
</Select>
{fields.country.errors && (
<p className="text-red">{fields.country.errors}</p>
)}
</div>
<Button type="submit" className="mt-5">
Submit
</Button>
</form>
);
}
実装内容の解説
バリデーション
バリデーションにはzodを使用し、サーバーサイドとクライアントサイドの双方で利用可能なスキーマを定義します。
// schema/index.ts
import { z } from "zod";
export const DATA_SCHEMA = z.object({
name: z.string({ required_error: "Name is required" }),
country: z.string({ required_error: "country is required" }),
});
Server Actions
次に、Server Actionsを設定します。
ここでは、前述のバリデーションスキーマを使用して入力データを検証し、エラーがある場合はクライアントサイドにエラーメッセージを返します。
データが有効な場合はCRUD操作を実行する想定ですが、今回は主旨から外れるため省略します。
"use server";
import { DATA_SCHEMA } from "@/schema";
import { parseWithZod } from "@conform-to/zod";
export const serverActions = async (prevState: unknown, formData: FormData) => {
const submission = parseWithZod(formData, {
schema: DATA_SCHEMA,
});
// データが有効でない場合は、エラーをクライアントに返します。
if (submission.status !== "success") {
return submission.reply();
}
// データが有効な場合は、CRUD操作します。
// ただし今回は省略します。
};
このserverActions
関数は、フォームの送信データを受け取り、parseWithZod
でDATA_SCHEMA
に基づいた検証を行います。
検証に失敗した場合はエラーメッセージを返し、成功した場合は次の処理に進みます。
form
まず、useActionState
を使用して、前回の送信結果を保持するstate
と、フォームのaction
に渡すformAction
を取得します。
// useActionStateを使用して、状態とフォームのアクションを取得
const [state, formAction] = useActionState(serverActions, undefined);
-
state
前回の送信結果を保持するstate。バリデーションエラーや成功した場合の情報が格納されます。 -
formAction
フォームのaction
に渡すための関数。serverActions
を呼び出す際に利用します。
次に、useForm
を使用してフォームの状態管理とバリデーションの制御を行います。
バリデーションには、Server Actionsで使用したものと同じzodスキーマを利用します。
また、各フィールドのバリデーションはonBlur
で開始されるように設定します。
const [form, fields] = useForm({
lastResult: state,
onValidate({ formData }) {
return parseWithZod(formData, {
schema: DATA_SCHEMA,
});
},
shouldValidate: "onBlur",
});
詳細な設定内容
-
lastResult: state
useActionState
で取得したstate
と同期し、フォームの状態を最新の送信結果と連動させます。 -
onValidate({ formData })
zodスキーマを基にバリデーションルールを設定します。 -
shouldValidate: "onBlur"
各フィールドのバリデーションをonBlur
イベント(入力フィールドがフォーカスを失ったタイミング)で開始します。
最後にJSXです。
ここでは事前にインストールしておいたshadcn/uiのButton
、Input
、Select
、Label
を使用しています。
なお、shadcn/uiにはform
コンポーネントが提供されていますが、これはReact Hook Formのラッパーとして設計されているため、今回は利用していません。
また、form
のaction
にformAction
を渡すことで、Server Actionsの利用が可能になります。
<form
id={form.id}
onSubmit={form.onSubmit}
action={formAction}
noValidate
className="w-[300px] m-10"
>
{/* Name Field */}
<div>
<Label htmlFor={fields.name.id}>Name:</Label>
<Input
key={fields.name.key}
name={fields.name.name}
defaultValue={fields.name.initialValue}
/>
{fields.name.errors && <p className="text-red">{fields.name.errors}</p>}
</div>
{/* Country Field */}
<div>
<Label htmlFor={fields.country.id}>Country:</Label>
<Select
key={fields.country.key}
name={fields.country.name}
defaultValue={fields.country.initialValue}
onValueChange={(value) => {
form.update({
name: fields.country.name,
value,
});
}}
>
<SelectTrigger>
<SelectValue placeholder="Choose country" />
</SelectTrigger>
<SelectContent>
{["USA", "JAPAN", "China"].map((country, index) => (
<SelectItem key={index} value={country}>
{country}
</SelectItem>
))}
</SelectContent>
</Select>
{fields.country.errors && (
<p className="text-red">{fields.country.errors}</p>
)}
</div>
{/* Submit Button */}
<Button type="submit" className="mt-5">
Submit
</Button>
</form>
解説
-
<Input>
Nameフィールド用の入力コンポーネント。fields.name
から取得したプロパティを使用して設定しています。 -
<Select>
Countryフィールド用のセレクトボックス。値が変更された際にform.update
を呼び出し、状態を更新します。 -
エラーメッセージ
各フィールドのエラー状態に基づいて、エラーメッセージを表示します。 -
<Button>
送信ボタン。type="submit"
を指定してフォーム送信をトリガーします。
問題点
バリデーションを開始するタイミングをonBlur
に設定しましたが、Select
コンポーネントでは動作しませんでした。
調査の結果、shadcn/uiのベースとなっているradix-uiのSelect.Root
ではonBlur
を渡せない仕様であることが判明しました。
この問題に関する詳細は以下のissueで確認できます。
普段、onBlur
でバリデーションを設定する私にとって、この仕様はかなりの痛手です。
解決されることを願っていますが、どうやらradix-uiはあまり活発にメンテナンスされていないようなので、あまり期待はできませんね…。
終わりに
個人的に流行りものを組み合わせてみたかったので、非常に満足しています笑
参考
Discussion