ZodとuseFormStateを使ったNext.js / React Server Actionsにおけるバリデーション
この記事では、Zod
とuseFormState
を使った Next.js / React Server Actions におけるバリデーションを行う方法について紹介します。
Next.js の公式チュートリアルを進めるなかで、このあたりのコードが少しわかりにくいため、自身で調べた内容の備忘録を兼ねて記事として公開することにしました。
サンプル
Next.js 公式が提供してくれているサンプルアプリを元に説明をします。
この記事では以下の請求書登録画面とそのコードを元に説明を行います。
React Server Actions とは
クライアントのイベントをトリガーとしてサーバーサイドの関数を呼び出す機能です。
例えば以下のように、form
のaction
属性にサーバーサイドで扱う関数を渡すことができます。
// Server Component
export default function Page() {
// Action
async function create(formData: FormData) {
'use server';
// Logic to mutate data...
}
// Invoke the action using the "action" attribute
return <form action={create}>...</form>;
}
Next.js では、この action 属性は特別な prop とみなされており、サーバー アクションは裏で POST API エンドポイントを作成してくれているため、関数として渡すことができるらしいです。
useFormState について
useFormState
は(action, initialState)の二つの引数を取ります。
action
はサーバーサイド関数、initialState
は検証値がエラーであった場合のメッセージなどを保持することができます。
またuseFormState
は[state, dispatch]の二つの値を返します。
state
は検証結果が、dispatch
は登録したサーバーサイド関数が返ってきます。
useFormState を使ったバリデーション
初期値の登録
まずは入力値にエラーがあった場合のメッセージを格納する変数を定義します。
// ...
import { useFormState } from 'react-dom';
export default function Form({ customers }: { customers: CustomerField[] }) {
+ const initialState = { message: null, errors: {} };
}
今回はmessage
とerrors
を定義しています。message
はエラーがあることを、errors
は「各入力項目にどのようなエラーがあるか?」を格納するためのプロパティです。
useFormState の使用
次にuseFormState
を呼び出します。
// ...
import { useFormState } from 'react-dom';
export default function Form({ customers }: { customers: CustomerField[] }) {
const initialState = { message: null, errors: {} };
+ const [state, dispatch] = useFormState(createInvoice, initialState);
+
+ return <form action={dispatch}>...</form>;
}
ここでcreateInvoice
というサーバーサイドの関数をuseFormState
に登録します。
そして登録後取得できたdispatch
を action として登録します。
現時点では「何をしてるのかよくわからん」という感じですが、createInvoice
を見てみることでだいぶ解像度が上がります。
'use server';
import { z } from 'zod';
import { sql } from '@vercel/postgres';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
export type State = {
errors?: {
customerId?: string[];
amount?: string[];
status?: string[];
};
message?: string | null;
};
const FormSchema = z.object({
id: z.string(),
customerId: z.string({
invalid_type_error: 'Please select a customer.',
}),
amount: z.coerce.number().gt(0, { message: 'Please enter an amount greater than $0.' }),
status: z.enum(['pending', 'paid'], {
invalid_type_error: 'Please select an invoice status.',
}),
date: z.string(),
});
const CreateInvoice = FormSchema.omit({ id: true, date: true });
export async function createInvoice(prevState: State, formData: FormData) {
const validatedFields = CreateInvoice.safeParse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
if (!validatedFields.success) {
const errors = {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Missing Fields. Failed to Create Invoice.',
};
console.log(errors)
return errors;
}
const { customerId, amount, status } = validatedFields.data;
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
createInvoice
は引数にprevState
とformData
を取っています。
今回prevState
は使わないのですが、useFormState
へ渡すサーバーサイド関数にはこの引数が必須なので定義しています。値としては、前回の検証結果が入ってきます。formData
には入力された値が入ってきます。
Zod の活用
ここで見慣れないzod
というライブラリを使用していることがわかります。
Zod は簡単に言えばタイプガードライブラリです。
面倒な型検証をいい感じにやってくれます。
Next.js が公式チュートリアルで用いているあたり、Vercel の推しなのかなとも思えるのですがそれはさておき…
先のコードで見るべき場所の一つ目がこちらです。
const FormSchema = z.object({
id: z.string(),
customerId: z.string({
invalid_type_error: 'Please select a customer.',
}),
amount: z.coerce.number().gt(0, { message: 'Please enter an amount greater than $0.' }),
status: z.enum(['pending', 'paid'], {
invalid_type_error: 'Please select an invoice status.',
}),
date: z.string(),
});
Zod の基本的な使い方としてはSchema
と呼ばれる型定義を作成し、値やオブジェクトをそれに渡すことで型を検証してくれるという感じです。
検証の際にはpaser
とsafeParse
の二種類から選ぶことができます。これらの違いはZod の公式ドキュメントをみることでわかります。
以下はそこでのサンプルコードです。
import { z } from "zod";
// creating a schema for strings
const mySchema = z.string();
// parsing
mySchema.parse("tuna"); // => "tuna"
mySchema.parse(12); // => throws ZodError
// "safe" parsing (doesn't throw error if validation fails)
mySchema.safeParse("tuna"); // => { success: true; data: "tuna" }
mySchema.safeParse(12); // => { success: false; error: ZodError }
parse
の場合はエラーを throw していますが、safeParse
の場合はエラーではなく{ success: false; error: ZodError }
のように検証結果とdata
もしくはerror
を返しています。
Next.js のチュートリアルは Zod を活用してバリデーションをかけています。
バリデーション
では改めてcreateInvoice
を見てみましょう。
export async function createInvoice(prevState: State, formData: FormData) {
const validatedFields = CreateInvoice.safeParse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
if (!validatedFields.success) {
const errors = {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Missing Fields. Failed to Create Invoice.',
};
console.log(errors)
return errors;
}
const { customerId, amount, status } = validatedFields.data;
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
createInvoice
では safeParse により型を検証すると共にバリデーションも同時に行っています。
safeParse が失敗した場合に、safeParse が return してくるerror
を各プロパティごとに配列にする形に成形し、return します。
この時 return される値は、以下のようになっています。
{
errors: {
customerId: [ 'Please select a customer.' ],
amount: [ 'Please enter an amount greater than $0.' ],
status: [ 'Please select an invoice status.' ]
},
message: 'Missing Fields. Failed to Create Invoice.'
}
あとはこの検証結果をフロントでよしなに見せるだけです。
おわりに
そもそも無知なのでZod
を知らなかったのですが、なかなか便利に使えそうだなと思いました。
useFormState
はバリデーションを含めてサーバーサイド関数をうまく取り回すために使うためのフックという感じですね。
Discussion