⚡️

ZodとuseFormStateを使ったNext.js / React Server Actionsにおけるバリデーション

2024/01/04に公開

この記事では、ZoduseFormStateを使った Next.js / React Server Actions におけるバリデーションを行う方法について紹介します。

Next.js の公式チュートリアルを進めるなかで、このあたりのコードが少しわかりにくいため、自身で調べた内容の備忘録を兼ねて記事として公開することにしました。

https://nextjs.org/learn/dashboard-app

サンプル

Next.js 公式が提供してくれているサンプルアプリを元に説明をします。

https://nextjs.org/learn/dashboard-app

この記事では以下の請求書登録画面とそのコードを元に説明を行います。

React Server Actions とは

クライアントのイベントをトリガーとしてサーバーサイドの関数を呼び出す機能です。

例えば以下のように、formaction属性にサーバーサイドで扱う関数を渡すことができます。

// 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 を使ったバリデーション

初期値の登録

まずは入力値にエラーがあった場合のメッセージを格納する変数を定義します。

TypeScript
// ...
import { useFormState } from 'react-dom';

export default function Form({ customers }: { customers: CustomerField[] }) {
+  const initialState = { message: null, errors: {} };
}

今回はmessageerrorsを定義しています。messageはエラーがあることを、errorsは「各入力項目にどのようなエラーがあるか?」を格納するためのプロパティです。

useFormState の使用

次にuseFormStateを呼び出します。

TypeScript
// ...
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を見てみることでだいぶ解像度が上がります。

actions.ts
'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は引数にprevStateformDataを取っています。

今回prevStateは使わないのですが、useFormStateへ渡すサーバーサイド関数にはこの引数が必須なので定義しています。値としては、前回の検証結果が入ってきます。formDataには入力された値が入ってきます。

Zod の活用

ここで見慣れないzodというライブラリを使用していることがわかります。

Zod は簡単に言えばタイプガードライブラリです。

https://zod.dev/?id=introduction

面倒な型検証をいい感じにやってくれます。

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と呼ばれる型定義を作成し、値やオブジェクトをそれに渡すことで型を検証してくれるという感じです。

検証の際にはpasersafeParseの二種類から選ぶことができます。これらの違いは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