shadcn/ui・react-hook-form で汎用性高いフォームコンポーネントを作ってみた
はじめに
本記事では、shadcn/ui・react-hook-form を使用して汎用性高いフォームのコンポーネント設計について書いています。
shadcn/ui の公式ドキュメント通りに書くと、非常にコードの量が多くなってしまうので、共通化できる部分を切り出してコンポーネントを使い回せるようにしたかったのがモチベーションです。
前提
今回使用したフレームワーク/ライブラリです。
- Next.js v15
- zod v3
- react-hook-form v7
環境構築
フォームのコンポーネントを作成するまでの準備をしていきます。
-
pnpm のインストール(パッケージ管理ツールは pnpm でなくても問題ありません)
公式ドキュメント:https://pnpm.io/ja/installation
Mac の場合は下記のコマンドでインストールできます。brew install pnpm
-
Next.js の環境構築
pnpm dlx create-next-app shadcn-form-template
※ node のバージョンを 18.18.0・19.8.0 または 20.0.0 以上にする必要があります
-
shadcn/ui の環境構築
pnpm dlx shadcn@latest init -c ./
※ pnpm の場合は、ディレクトリを指定する必要がありました。
- 必要なコンポーネントの追加
pnpm dlx shadcn@latest add button form input
- サーバー起動
pnpm dev
改善前
公式ドキュメントを参考にフォームのコンポーネントを作成すると、下記のような実装になります。
"use client"
import { Input } from "@/components/ui/input"
import {
Form,
FormControl,
FormField,
FormMessage,
FormLabel,
FormItem,
} from "@/components/ui/form"
import { Button } from "@/components/ui/button"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
const FormSchema = z.object({
email: z.string().email({
message: "メールアドレスの形式が正しくありません",
}),
password: z
.string()
.min(8, {
message: "パスワードは8文字以上で入力してください",
})
.regex(/(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])/, {
message:
"パスワードは数字・英小文字・英大文字をそれぞれ1文字以上使用してください",
}),
})
type FormSchemaType = z.infer<typeof FormSchema>
export default function FormPage() {
const form = useForm<FormSchemaType>({
defaultValues: {
email: "",
password: "",
},
resolver: zodResolver(FormSchema),
})
const onSubmit = form.handleSubmit((data) => {
console.log(data)
})
return (
<Form {...form}>
<form
onSubmit={onSubmit}
className="grid grid-cols-1 gap-4 max-w-sm mx-auto mt-6"
>
<FormField
control={form.control}
name={"email"}
render={({ field }) => (
<FormItem>
<FormLabel>メールアドレス</FormLabel>
<FormControl>
<Input
{...field}
placeholder="メールアドレスを入力してください"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={"password"}
render={({ field }) => (
<FormItem>
<FormLabel>パスワード</FormLabel>
<FormControl>
<Input {...field} placeholder="パスワードを入力してください" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">送信</Button>
</form>
</Form>
)
}
このような書き方は入力項目が増えるたびに、<FormField/>
,<FormItem/>
,<FormControl/>
などフォームを構成するためのコンポーネントを定義する必要があり、コードの見通しが悪くなります。また、コードの書く量も増えるので開発速度が低下します。
改善後
このような問題点を改善するためにフォーム用の Input コンポーネントを作成しました。
import React from "react"
import { FieldValues, UseControllerProps } from "react-hook-form"
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input, InputProps } from "@/components/ui/input"
export type FormInputProps<T extends FieldValues> = InputProps &
UseControllerProps<T> & {
label: string
}
export function FormInput<S extends FieldValues>({
name,
control,
label,
...inputProps
}: FormInputProps<S>) {
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormControl>
<Input
{...inputProps}
onChange={field.onChange}
value={field.value}
onBlur={field.onBlur}
disabled={field.disabled}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)
}
ポイント
form.control
とname
を受け取れようにするために、型エイリアスにジェネリクスを使用したUseControllerProps<S>
を宣言する必要があります。(上記のコードは、ジェネティクスの部分を区別するために S
とT
で書き分けています。)
さらに、このS
はreact-hook-form
側で宣言されているFieldValues
という型エイリアスを継承しているので、自前で定義する時もFieldValues
を継承しないと型エラーが出ます。
ちなみにFieldValues
の実態は、Record<string, any>
なので、これをそのまま継承しても型エラーは出ません。(any を書くので lint に怒られると思いますが)
改善後のフォームのコンポーネント
フォーム側はFormInput
を呼び出せばいいので、コードがだいぶスッキリしました。
"use client"
import { Form } from "@/components/ui/form"
import { Button } from "@/components/ui/button"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { FormInput } from "@/components/form/form-input"
const FormSchema = z.object({
email: z.string().email({
message: "メールアドレスの形式が正しくありません",
}),
password: z
.string()
.min(8, {
message: "パスワードは8文字以上で入力してください",
})
.regex(/(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])/, {
message:
"パスワードは数字・英小文字・英大文字をそれぞれ1文字以上使用してください",
}),
})
type FormSchemaType = z.infer<typeof FormSchema>
export default function FormPage() {
const form = useForm<FormSchemaType>({
defaultValues: {
email: "",
password: "",
},
resolver: zodResolver(FormSchema),
})
const onSubmit = form.handleSubmit((data) => {
console.log(data)
})
return (
<Form {...form}>
<form
onSubmit={onSubmit}
className="grid grid-cols-1 gap-4 max-w-sm mx-auto mt-6"
>
<FormInput
control={form.control}
name="email"
label="メールアドレス"
placeholder="メールアドレスを入力してください"
/>
<FormInput
control={form.control}
name="password"
label="パスワード"
placeholder="パスワードを入力してください"
/>
<Button type="submit">送信</Button>
</form>
</Form>
)
}
以上が、shadcn/ui・react-hook-form を使用して汎用性高いフォームのコンポーネント設計についてになります。
コンポーネントの再利用性を意識してどこを共通化するかが難しいですが、今回のコンポーネント設計は再利用性高くできたかなと思います。
もっといい設計案があれば、ぜひ教えてください。
参考までにリポジトリを添付します。
最後までお読みいただきありがとうございました。
参考
Discussion