Next.js × Rails × React Hook Form × Zod で画像付きフォームを実装する

画像付きの商品登録フォームを作るとき、
最初は useState と onChange ハンドラー、
自作のバリデーションロジックでも十分構築できます。
ただ、フォーム項目が増えたり、
バリデーションの条件が複雑になってくると、
状態管理やエラーメッセージの扱いが面倒になりがちです。
そこで今回は、Next.js × React Hook Form × Zod を使って、
画像アップロードに対応した商品登録フォームを実装しながら、
フォーム構築の負担を軽減する方法を整理してみました。
バックエンドには Rails を使用しており、
Next.js の API 経由で multipart/form-data による画像送信を行います。
リポジトリはこちら
https://github.com/shu915/digital_ichiba_frontend
https://github.com/shu915/digital_ichiba_backend
商品の新規登録ページ
ファイルは、src/app/dashboard/shop/product/new/page.tsx
このディレクトリ階層とページは、
Next.jsのApp Routerで、
ログインしたショップオーナーが、
商品を新規登録するためのページです。
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { requireAuth } from "@/lib/requireAuth";
import requireShopOrAdmin from "@/lib/requireShopOrAdmin";
import PageTitle from "@/components/atoms/PageTitle";
import ShopProductsNewForm from "./ShopProductsNewForm";
export default async function ShopProductsNew() {
await requireAuth();
await requireShopOrAdmin();
return (
<div className="py-8 inner">
<PageTitle title="商品新規登録" />
<div className="flex justify-end mt-4 gap-2">
<Button asChild>
<Link href="/dashboard/shop">
<span className="font-bold">ショップダッシュボードへ</span>
</Link>
</Button>
</div>
<ShopProductsNewForm />
</div>
);
}
requireAuth()でログインしている必要があり、
requireShopOrAdmin()で、ロールがショップか管理人である必要があります。
またショップのダッシュボードに飛ぶリンクがあります。
今回の本題は、ShopProductsNewFormなので、
それ以外ところは割愛します。
商品の新規登録フォーム
ファイルは、src/app/dashboard/shop/product/new/ShopProductsNewForm.tsx
先ほどのページからこのコンポーネントを呼び出しています。
"use client";
import { useRef } from "react";
import { useForm, Controller } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
import { imageFileRequired } from "@/lib/formImageSchema";
export default function ShopProductsNewForm() {
const schema = z.object({
product_name: z
.string()
.min(1, "名前は必須です")
.max(120, "120文字以内で入力してください"),
product_description: z.string().max(500, "500文字以内で入力してください"),
product_price: z
.number({ message: "整数で入力してください" })
.int({ message: "整数で入力してください" })
.min(0, "0以上の整数で入力してください"),
product_stock: z
.number({ message: "整数で入力してください" })
.int({ message: "整数で入力してください" })
.min(0, "0以上の整数で入力してください"),
product_image: imageFileRequired,
});
type FormValues = z.infer<typeof schema>;
const formRef = useRef<HTMLFormElement>(null);
const {
control,
handleSubmit,
register,
reset,
resetField,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
product_name: "",
product_description: "",
product_price: 0,
product_stock: 0,
},
});
const onSubmit = async (data: FormValues) => {
const formData = new FormData();
formData.append("product[name]", data.product_name);
formData.append("product[description]", data.product_description);
formData.append("product[price]", String(data.product_price));
formData.append("product[stock]", String(data.product_stock));
formData.append("product[image]", data.product_image);
const res = await fetch("/api/products", {
method: "POST",
body: formData,
cache: "no-store",
});
if (res.ok) {
reset({
product_name: "",
product_description: "",
product_price: 0,
product_stock: 0,
});
resetField("product_image");
formRef.current?.reset();
toast("商品を作成しました");
} else {
toast("商品の作成に失敗しました");
}
};
return (
<form
ref={formRef}
onSubmit={handleSubmit(onSubmit)}
className="w-3xl mx-auto p-4 flex flex-col gap-6"
>
<div className="flex flex-col gap-1">
<label htmlFor="product_name" className="font-bold">
名前(必須)
</label>
{errors.product_name && (
<p className="text-red-500 text-sm">{errors.product_name.message}</p>
)}
<Input
id="product_name"
placeholder="商品名"
{...register("product_name")}
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="product_description" className="font-bold">
説明
</label>
{errors.product_description && (
<p className="text-red-500 text-sm">
{errors.product_description.message}
</p>
)}
<Textarea
id="product_description"
placeholder="500文字以内で入力"
className="resize-none h-32"
{...register("product_description")}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-1">
<label htmlFor="product_price" className="font-bold">
税抜き価格
</label>
{errors.product_price && (
<p className="text-red-500 text-sm">
{errors.product_price.message}
</p>
)}
<Input
id="product_price"
type="number"
min={0}
step="1"
inputMode="numeric"
placeholder="0"
{...register("product_price", { valueAsNumber: true })}
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="product_stock" className="font-bold">
在庫
</label>
{errors.product_stock && (
<p className="text-red-500 text-sm">
{errors.product_stock.message}
</p>
)}
<Input
id="product_stock"
type="number"
min={0}
step="1"
inputMode="numeric"
placeholder="0"
{...register("product_stock", { valueAsNumber: true })}
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="product_image" className="font-bold">
画像(必須)
</label>
{errors.product_image && (
<p className="text-red-500 text-sm">
{errors.product_image.message as string}
</p>
)}
<Controller
name="product_image"
control={control}
render={({ field }) => (
<Input
id="product_image"
type="file"
accept="image/*"
onChange={(e) => field.onChange(e.target.files?.[0])}
/>
)}
/>
</div>
<Button
type="submit"
disabled={isSubmitting}
className="w-48 mx-auto max-w-full font-bold"
>
作成する
</Button>
</form>
);
}
このフォームは、商品登録フォームで、
名前、説明文、税抜き価格、在庫数、画像を登録します。
画像は独自のzのカスタムスキーマを作ります。
全体像を掴むことが大事なので、
先にフォーム全体の解説をしてから、
画像のzのカスタムスキーマの解説をします。
"use client";
このコンポーネントは、
クライアントサイドで、値を動的に扱うので、
クライアントコンポーネントにします。
import { useRef } from "react";
商品の新規登録したあと、
ポップアップで成功を知らせています。
ページ遷移がないので、
useRefを使ってフォームを空っぽにすることで、
次の商品を挿入できるようにします。
import { useForm, Controller } from "react-hook-form";
useForm はフォーム全体の状態管理、
Controller は画像ファイルのように register では扱いにくい入力項目を連携させるために使います。
import { z } from "zod";
z は Zod ライブラリのオブジェクトで、
フォーム入力のバリデーションルール(スキーマ)を定義するために使います。
import { zodResolver } from "@hookform/resolvers/zod";
zodResolver は、Zod のバリデーションを React Hook Form に統合するための関数です。
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
shadcn-uiのUIコンポーネントをインポートしています。
ボタンとインプットとテキストエリアをインポートしています。
toastというのは、送信成功時などに出す、ポップアップです。
import { imageFileRequired } from "@/lib/formImageSchema";
これは画像ファイル用の自作のZodカスタムバリデーションスキーマです。
全体像の解説を優先するため、後で詳しく解説します。
export default function ShopProductsNewForm() {
デフォルト関数として定義しています。
const schema = z.object({
z.object()は、オブジェクトを引数に取ることで、バリデーションのルールを定義します。
product_name: z
.string()
.min(1, "名前は必須です")
.max(120, "120文字以内で入力してください"),
product_nameをオブジェクトのキーとして定義しています。
このキーは React Hook Form 側でも register(“product_name”) や
Controller の name プロパティで使うため、
ここで定義したものと一致させておく必要があります。
z.string().min().max(),
これは文字列であることを制限し、
最小文字数と最大文字数を指定しています。
min()は第一引数に最小文字数を入れて、
第二引数にバリデーションエラーのメッセージを入れます。
max()も同じように、第一引数に最大文字数を入れて、
第二引数にバリデーションエラーのメッセージを入れます。
product_description: z.string().max(500, "500文字以内で入力してください"),
商品の説明文は省略可能なので、min()がありません。
ほかは同じです。
product_price: z
.number({ message: "整数で入力してください" })
.int({ message: "整数で入力してください" })
.min(0, "0以上の整数で入力してください"),
これは税抜き価格なので、
number()で数字であることを、
int()で整数であることを、
min()で0以上の整数であることを指定しています。
0以上とすることで、無料のものも置くことができます。
product_stock: z
.number({ message: "整数で入力してください" })
.int({ message: "整数で入力してください" })
.min(0, "0以上の整数で入力してください"),
これは在庫数なので、
number()で数字であることを、
int()で整数であることを、
min()で0以上の整数であることを指定しています。
在庫を0以上とすることで、在庫が0でも登録できるようにしています。
product_image: imageFileRequired,
これは画像ファイルを扱うバリデーションのスキーマです。
別のファイルでz.customで定義したカスタムスキーマを値として渡しています。
詳細は後で説明します。
});
これでz.objectを閉じます。
type FormValues = z.infer<typeof schema>;
Zodで定義したバリデーションスキーマ(schema)から、
自動的に型情報を抽出して FormValues という型として定義しています。
typeof schemaでschemaの型を取得して、
Schemaを定義して、に代入してみると
type Schema = z.ZodObject<{
product_name: z.ZodString;
product_description: z.ZodString;
product_price: z.ZodNumber;
product_stock: z.ZodNumber;
product_image: z.ZodCustom<File, File>;
}, z.core.$strip>
schema の型は上記のになっており、ZodObjectであるため、
そのまま使うことはできません。
z.infer<…> を使うことで、
Zodが保持しているバリデーション定義から、
実際の入力データの型を抽出できます。
その型を FormValues という名前で定義しています。
type FormValues = {
product_name: string;
product_description: string;
product_price: number;
product_stock: number;
product_image: File;
}
型はこのようになっていて、
z.objectが書き換えられると、こちらも変わります。
const formRef = useRef<HTMLFormElement>(null);
useRefにTSがあらかじめ用意しているHTMLFormElementの型を渡します。
これで、このuseRefが扱うものをフォームのエレメントに制限します。
初期値はnullで、formRefという名前で定義します。
これはあとで、送信成功したときに、
フォームを空にするのに使います。
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
product_name: "",
product_description: "",
product_price: 0,
product_stock: 0,
},
});
先にuseFormと引き取るものを見ていきます。
useForm は、
フォーム全体の状態(入力値、エラー、送信中フラグなど)を
まとめて管理するReact Hook Formのメインフックです。
Reactでフォームを扱う際の「状態管理」「バリデーション」「送信処理」を1つに統合できます。
型引数で先ほどのFormValuesを引き取ります。
引数はオブジェクトで引き取ります。
resolver に zodResolver(schema) を指定することで、
Zodのスキーマを使った型安全なバリデーションが可能になります。
フォーム送信時にはこのスキーマに従って入力チェックが自動で行われ、
エラーは errors に格納されます。
defaultValues はフォームの初期状態を定義します。
ここで設定した値は、reset()を呼び出すとリセット後に再び反映されます。
const {
control,
handleSubmit,
register,
reset,
resetField,
formState: { errors, isSubmitting },
次に、useFormの返すものを見ていきます。
control
Controllerを使うとき、controlをControllerにわたします。
Controllerは画像ファイルを扱うのに使います。
handleSubmit
<form onSubmit={handleSubmit(onSubmit)}>
とすることで、バリデーションを付与します。
register
inputやtextareaを登録するのに使います。
reset
フォームの値をリセットするのに使います
resetField
画像のアップロードをリセットするのに使います。
formState: { errors, isSubmitting },
formStateは様々の状態を管理しており、
errorsとisSubmittingを出しておきます。
errorsの中にエラーやエラーメッセージが格納されます。
isSubmittingは、フォーム送信中にtrueになるので、
その間送信ボタンを押せないようにするのに使います。
const onSubmit = async (data: FormValues) => {
送信用の関数を定義しています。
dataはFormValuesを型に取ります。
dataはオブジェクトであり、
フォームのname属性やregisterで登録した名前がキーになり、
ユーザーが入れた値が値になり、
FormValuesと型が一致する必要があります。
const formData = new FormData();
formData.append("product[name]", data.product_name);
formData.append("product[description]", data.product_description);
formData.append("product[price]", String(data.product_price));
formData.append("product[stock]", String(data.product_stock));
formData.append("product[image]", data.product_image);
ファイルがなければjsonでいいのですが、
ファイルがあるのでFormDataを使ってAPI通信します。
new FormData()でインスタンスを作って、formDataに入れます。
appendの第一引数は、バックエンドに送るためのキーで、
第二引数が送りたい値です。
def create_product_params
params.require(:product).permit(:name, :description, :price, :stock, :image)
end
キーを”product[name]”のように記述しているのは
Railsで上記のように受け取るためです。
バリューはdata.product_nameのようにセットします。
税抜き価格と在庫数は送信するため1回文字列に変換します。
const res = await fetch("/api/products", {
method: "POST",
body: formData,
cache: "no-store",
});
クライアントコンポーネントの中から、
Next.jsのバックエンドのproxyにfetchしているので、
相対パスです。
proxyの詳細解説はこちら
メソッドはPOSTで、
bodyに先ほど作ったformDataを入れます。
キャッシュはしません。
返り値をresに代入します。
if (res.ok) {
reset({
product_name: "",
product_description: "",
product_price: 0,
product_stock: 0,
});
resetField("product_image");
formRef.current?.reset();
toast("商品を作成しました");
} else {
toast("商品の作成に失敗しました");
}
res.okならば
resetを使ってフォームの値をリセットします。
画像ファイルはresetFieldでリセットします。
これで仮想DOMの値はリセットされましたが、
このままではブラウザ上のフォーム(実際の入力欄)には以前の値が残っています。
formRef.current?.reset();でユーザーが見ている値もリセットさせます。
formRef.current には <form> 要素が入っているので、
これは実質 form.reset() を実行しているのと同じです。
return (
<form
ref={formRef}
onSubmit={handleSubmit(onSubmit)}
className="w-3xl mx-auto p-4 flex flex-col gap-6"
>
ここからはレンダリングするJSXの中です。
CSSの解説は割愛します。
フォームタグを書きます。
ref={formRef}で、
formRefにこのフォーム要素が代入されます。
onSubmit={handleSubmit(onSubmit)}
このように書くことで、フォーム送信時に handleSubmit がまず実行され、
Zod で定義したバリデーションを通過した場合のみ、
自分が定義したonSubmit 関数が呼び出されます。
<div className="flex flex-col gap-1">
<label htmlFor="product_name" className="font-bold">
名前(必須)
</label>
{errors.product_name && (
<p className="text-red-500 text-sm">{errors.product_name.message}</p>
)}
<Input
id="product_name"
placeholder="商品名"
{...register("product_name")}
/>
</div>
ここは商品名を入れるところなので
Inputでidを設定してlabelでクリックできるようにして、
プレースホルダーを設定します。
ポイントは{…register(“product_name”)}と書くことです。
register()に引数で、”product_name”を渡すことで、
product_nameに関する、
input要素に渡す属性名と属性値がオブジェクトで返ってくるので、
それをスプレッド構文で展開しています。
errors.product_nameがtrueの場合は
errors.product_name.messageを出力させて、
ユーザーに知らせます。
説明のラベルとフィールドはほとんど同じなので省略します。
Inputの代わりにTextareaになっていること以外は、
商品名と同じです。
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-1">
<label htmlFor="product_price" className="font-bold">
税抜き価格
</label>
{errors.product_price && (
<p className="text-red-500 text-sm">
{errors.product_price.message}
</p>
)}
<Input
id="product_price"
type="number"
min={0}
step="1"
inputMode="numeric"
placeholder="0"
{...register("product_price", { valueAsNumber: true })}
/>
</div>
これは税抜き価格です。
違いがあるところだけを説明します。
Inputはtype=”number”とすることで、数字を強制します。
min={0}とすることで、マイナスが入らないようにします。
step=”1″とすることで、1刻みに増減します。
inputMode=”numeric”で、モバイル端末で数字キーボードがでます。
{…register(“product_price”, { valueAsNumber: true })}
この第二引数のオプションを渡すことで、
ユーザーが入力したものを数字として扱うようになります。
在庫の構造は税抜き価格とは同じなので省略します。
<div className="flex flex-col gap-1">
<label htmlFor="product_image" className="font-bold">
画像(必須)
</label>
{errors.product_image && (
<p className="text-red-500 text-sm">
{errors.product_image.message as string}
</p>
)}
<Controller
name="product_image"
control={control}
render={({ field }) => (
<Input
id="product_image"
type="file"
accept="image/*"
onChange={(e) => field.onChange(e.target.files?.[0])}
/>
)}
/>
</div>
ここでは画像ファイルアップロードを扱っています。
Controllerはregisterで扱えない、ファイル、セレクト、日付などを扱うのに使います。
control={control}
control={control}でuseFormの返り値のcontrolをわたします。
render={({ field }) => (
renderのところは少し複雑です。
({ field })のところの()はアロー関数の引数を入れるカッコで
{ filed }というのはControllerからオブジェクトが渡ってきて、
その中のキーがfiledのものの値を分割代入しています。
(<Input />)となっているので、これはJSXとして<Input />を返しています。
type=”file”で、ファイルを指定します。
accept=”image/*”で拡張子をpng、jpeg、webpなどに制限します。
onChange={(e) => field.onChange(e.target.files?.[0])}
ファイルが選択されると onChange が発火し、
field.onChange に選択したファイルを渡すことで、
React Hook Form に値が反映されます。
このようにControllerを使って、内部でInputを返すことで、
ファイルアップロードを可能にできます。
<Button
type="submit"
disabled={isSubmitting}
className="w-48 mx-auto max-w-full font-bold"
>
作成する
</Button>
送信ボタンです。
既に送信中の場合はdisabled={isSubmitting}で連打できないようになっています。
画像ファイル用のZodカスタムスキーマ
"use client";
import { z } from "zod";
const isFile = (v: unknown): v is File =>
typeof File !== "undefined" && v instanceof File; // SSRでも安全
const ACCEPTED_IMAGE_TYPES: string[] = [
"image/png",
"image/jpeg",
"image/webp",
];
const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5MB
export const imageFileRequired = z
.custom<File>((f) => isFile(f), { message: "画像ファイルを選択してください" })
.refine(
(f) => f.size <= MAX_IMAGE_BYTES,
"ファイルサイズは5MB以下にしてください"
)
.refine(
(f) => ACCEPTED_IMAGE_TYPES.includes(f.type),
"対応している形式は PNG / JPG / WEBP のみです"
);
export const imageFileOptional = imageFileRequired.optional();
lib/formImageSchema.tsにこのファイルを作ります。
全体像を優先するために、後で説明しますと先延ばしにしていた、
Zodのカスタムスキーマを解説します。
"use client";
import { z } from "zod";
クライアントコンポーネントを宣言します。
zをzodからインポートします。
const isFile = (v: unknown): v is File =>
typeof File !== "undefined" && v instanceof File; // SSRでも安全
アロー関数を定義してisFileに代入しています。
処理部の{}とreturnは省略しています。
引数はvとして引き取ります。
unknown型にすることで、形が不明な値を安全に扱うことができます。
返り値はv is Fileとなっていて、
Fileであればtrueになります。
typeof File !== “undefined”というのは、
サーバーサイドのjsにはFileクラスがないからです。
instanceof Fileをサーバーサイドで実行するとエラーになるため、
ブラウザ環境であることを確認してから、
後ろのv instanceof Fileを実行しています。
v instanceof FileでvがFileクラスのインスタンスであることを確認します。
つまり、この関数の引数にファイルを入れると
ブラウザ環境であることを確認してから、
Fileクラスのインスタンスであれば、
アップロードしたものはファイルなので、
trueを返す関数を作っています。
const ACCEPTED_IMAGE_TYPES: string[] = [
"image/png",
"image/jpeg",
"image/webp",
];
配列で許可する、画像タイプのMIMEを定義しています。
jpgは”image/jpeg”に含まれます。
const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5MB
サイズを5MBまでに制限しています。
export const imageFileRequired = z
.custom<File>((f) => isFile(f), { message: "画像ファイルを選択してください" })
.refine(
(f) => f.size <= MAX_IMAGE_BYTES,
"ファイルサイズは5MB以下にしてください"
)
.refine(
(f) => ACCEPTED_IMAGE_TYPES.includes(f.type),
"対応している形式は PNG / JPG / WEBP のみです"
);
imageFileRequiredとして定義します。
画像ファイルのアップロードを必須とします。
z.custom<File>((f) => isFile(f), { message: "画像を選択してください" })
これはメソッドチェーンの最初のcustomに対して型引数を渡しています。
Fileを渡すことでFileクラスのインスタンスであることを制限します。
第一引数で、
引き取ったファイルをfとして、isFileに渡して、
ファイルと確認したらtrueを返します。
第二引数は、エラーになったときのメッセージです。
.refine(
(f) => f.size <= MAX_IMAGE_BYTES,
"ファイルサイズは5MB以下にしてください"
)
refine()でバリデーションに条件を追加できます。
第一引数はアロー関数で、=>はアロー関数のアローで、
右にある<=は矢印ではなく
MAX_IMAGE_BYTES以下(つまり5MB以下)という意味です。
第二引数はエラーメッセージです。
.refine(
(f) => ACCEPTED_IMAGE_TYPES.includes(f.type),
"対応している形式は PNG / JPG / WEBP のみです"
);
f.typeでMIMEが取得できます。
ACCEPTED_IMAGE_TYPESがMIMEの入った配列なので、
その中に含まれていればtrueを返します。
export const imageFileOptional = imageFileRequired.optional();
これは今回は使いませんが、
.optional()をつけると選択制になります。
画像アップロードが任意のときに使います。
import { imageFileRequired } from "@/lib/formImageSchema";
//略
const schema = z.object({
//略
product_image: imageFileRequired,
});
実際使っている場面で、
インポートしたあと、
このようにz.object({})の中で、
スキーマを渡しています。
まとめ
Next.js × React Hook Form × Zod を使うことで、
型安全で扱いやすい画像アップロードフォームを構築できました。
- React Hook Form:フォームの状態管理と送信処理を一元化
- Zod:型定義とバリデーションを統合
- Controller:ファイル入力のような特殊項目を柔軟に扱える
- z.custom()+refine():ファイルサイズや形式を安全に検証
- FormData:画像を含むデータを簡潔に送信
この構成を押さえておけば、
画像付きフォームの実装はスムーズに拡張できます。