Server Actionsは、Next.jsのバージョン13.4で登場し、バージョン14からStable(安定版)となった新機能です。

Server Actionsを使用しない場合、フォームに入力された値をデータベースに保存するには、APIエンドポイントを作成し、クライアント(ブラウザ)からfetch関数などを使ってAPIエンドポイントにリクエストを送信する必要があります。一方、Server Actionsを利用する場合、サーバー側で実行される関数として定義されるため、フォームデータを直接サーバーに送信し、そのままデータベース操作を行うことができます。また、この機能を使うことで、プログレッシブエンハンスメント対応のフォームを作成できます。Server Actionsが通常のHTMLフォームと互換性を持つため、JavaScriptが利用できない環境(JavaScriptが無効でも同様)でもサーバー側で処理が行えるからです。

プログレッシブエンハンスメントは、すべてのユーザーに最小限の機能を提供し、機能の利用可能性の改善に合わせて段階的に機能を追加することで、ウェブサイトやウェブアプリケーションの正常な表示や機能を実現する設計手法です。by Claude
fukidashi

Server Actions を理解するのは言葉だけの説明だけでは難しいと感じる人も多いのではないでしょうか。本書では、具体例としてシンプルなTodoアプリケーションを題材に、Server Actionsを活用してCRUD(Create, Read, Update, Delete)操作を実装する方法を解説します。まずはCRUD操作の基本を確認し、その後にZodを使ったバリデーションを追加して、エラー処理の実践的な方法も学べる構成です。これにより、Server Actionsの仕組みだけでなく、安全で堅牢なアプリケーションを作成するためのポイントも身につけることができます。

Next.jsでは、ルーティング方法としてApp RouterとPages Routerの2種類を選択できます。しかし、Server Actionsを利用する場合は、App Routerを採用する必要があります。これは、Server ActionsがNext.jsの新しいアーキテクチャであるApp Routerに統合されており、Pages Routerでは対応していないためです。

App Routerを使用することで、Server Actionsを活用した効率的なデータ処理や、サーバーサイドとクライアントサイドを組み合わせた柔軟な機能を最大限に引き出せます。

データベースには Prisma 経由で SQLite データベースを利用します。

プロジェクトの作成

プロジェクトの作成は”npx create-next-app@latest”コマンドを利用して行います。プロジェクト名には任意の名前をつけることができますがここでは nextjs-14-server-actions という名前をつけています。コマンドを実行すると TypeScript や ESLint などを利用するか聞かれますが src ディレクトリを除いてすべて”Yes”を設定しています。


% npx create-next-app@latest nextjs-14-server-actions
Need to install the following packages:
create-next-app@14.0.2
Ok to proceed? (y) y
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes
Creating a new Next.js app in /Users/mac/Desktop/next-14-server-actions.

Using npm.

Initializing project with template: app-tw


Installing dependencies:
- react
- react-dom
- next

Installing devDependencies:
- typescript
- @types/node
- @types/react
- @types/react-dom
- autoprefixer
- postcss
- tailwindcss
- eslint
- eslint-config-next
added 331 packages, and audited 332 packages in 24s

116 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
Initialized a git repository.

Success! Created next-14-server-actions at /Users/mac/Desktop/next-14-server-actions

インストール後に package.json ファイルを確認します。インストールした Next.js のバージョンは 14.0.2 であることがわかります。


{
  "name": "nextjs-14-server-actions",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "react": "^18",
    "react-dom": "^18",
    "next": "14.0.2"
  },
  "devDependencies": {
    "typescript": "^5",
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "autoprefixer": "^10.0.1",
    "postcss": "^8",
    "tailwindcss": "^3.3.0",
    "eslint": "^8",
    "eslint-config-next": "14.0.1"
  }
}

データベースの設定

本書では、データベースとしてSQLiteを使用します。SQLiteは、クラウドサービスのアカウントを作成する手間が不要で、特にmacOSではデフォルトでインストールされているため、すぐに利用できる便利なデータベースです。

Vercel のアカウントを持っている場合には Vercelのpostgresを利用することも可能です。
fukidashi

本書では、SQLiteデータベースを操作するためにPrismaというORM(Object-Relational Mapping)ツールを使用します。Prismaを利用すれば、設定ファイルでスキーマを定義するだけで、SQLを直接記述することなくテーブルを作成できます。

データベース操作は、生成されたオブジェクトのメソッドを通じて行えるため、直感的で効率的です。また、Prismaで定義したスキーマ情報はTypeScriptの型情報としても活用でき、型安全なコードを実現します。

Prismaは、スキーマ定義からデータベース操作、型安全な開発まで一貫したサポートを提供する便利なツールです。
fukidashi

Prisma のインストール

npm コマンドを利用して Prisma のインストールを行います。


 % npm install prisma --save-dev
コマンド実行後にインストールされた Prisma のバージョンは 5.5.2 でした。
fukidashi

Prisma 用の設定ファイルを作成するために npx prisma init コマンドを実行します。実行するとプロジェクトディレクトリ下には prisma ディレクトリと.env ファイルが作成されます。prisma ディレクトリには Prisma の設定ファイルである schema.prisma ファイルが作成されています。.env ファイルはデータベースに接続するために必要となる環境変数を設定するために利用します。

SQLite データベースを利用するのでオプション–datasource-provider に sqlite を設定しています。もし指定しない場合には postgresql データベースが接続データベースとして設定された状態でファイルが作成されます。


 % npx prisma init --datasource-provider sqlite

✔ Your Prisma schema was created at prisma/schema.prisma
  You can now open it in your favorite editor.

warn You already have a .gitignore file. Don't forget to add `.env` in it to not commit any private information.

Next steps:
1. Set the DATABASE_URL in the .env file to point to your existing database. If your database has no tables yet, read https://pris.ly/d/getting-started
2. Run prisma db pull to turn your database schema into a Prisma schema.
3. Run prisma generate to generate the Prisma Client. You can then start querying your database.

More information in our documentation:
https://pris.ly/d/getting-started

SQLite データベースではファイルを利用してデータを保存するため.env ファイルにはデータベースファイルを保管するパスとファイル名が環境変数 DATABASE_URL に設定されています。

schema.prisma ファイルでは–datasource-provider を指定して実行した場合は SQLite データベースに関する設定は完了しているのでモデルの設定を行います。モデルには todo テーブルを作成するためのスキーマ(テーブルの構成情報)を追加します。String タイプの name と Boolean タイプの isCompleted を設定しています。


// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model Todo {
  id  Int @id @default(autoincrement())
  name  String
  isCompleted Boolean @default(false)
}

schema.prisma ファイルでのモデルの完了したら SQLite データベースに todo テーブルを作成するために npx prisma db push コマンドを実行します。


 % npx prisma db push
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": SQLite database "dev.db" at "file:./dev.db"

SQLite database dev.db created at file:./dev.db

🚀  Your database is now in sync with your Prisma schema. Done in 14ms

Running generate... (Use --skip-generate to skip the generators)

added 2 packages, and audited 336 packages in 2s

116 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

✔ Generated Prisma Client (v5.5.2) to ./node_modules/@prisma/client in 120ms

コマンドを実行すると.env ファイルの DATABASE_URL で指定した場所に SQLite のデータベースファイル dev.db が作成されます

Prisma Studio からのデータベース接続

Prisma には Prisma Studio という Prisma 専用の GUI ツールを利用してデータベースにアクセスを行うことができます。Prisma Studio を起動するために npx prisma studio コマンドを実行します。


% npx prisma studio
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Prisma Studio is up on http://localhost:5555

ブラウザから http://localhost:5555 にアクセスするとブラウザ上から todo テーブルを確認することができます。

Prisma StudioからのTodoテーブルの確認
Prisma StudioからのTodoテーブルの確認

ブラウザ上からデータを登録することも可能なので動作確認に利用する 2 件のデータを登録します。

データの登録
データの登録

Prisma Client の設定

Next.js からデータベースに接続するために Prisma Client の設定を行う必要があります。設定を行うためにlibディレクトリをプロジェクトディレクトリの直下に作成してその下に prisma.ts ファイルを作成します。


declare global {
  var prisma: PrismaClient;
}

import { PrismaClient } from '@prisma/client';

let prisma: PrismaClient;

if (process.env.NODE_ENV === 'production') {
  prisma = new PrismaClient();
} else {
  if (!global.prisma) {
    global.prisma = new PrismaClient();
  }
  prisma = global.prisma;
}

export default prisma;

Next.js からデータベースに接続する場合はこのファイルから Prisma Client の import を行います。これで Prisma の設定は完了です。

Todo 一覧の表示

データベースに保存された todo テーブルの一覧をブラウザ上に表示するために app ディレクトリに todos ディレクトリを作成して page.tsx ファイルを作成します。page.tsx ファイルを作成することで Next.js が自動でルーティングを行い、ブラウザから/todos でアクセスすることが可能になります。

page.tsx では prisma クライアントを利用して SQLite データベースの todo テーブルにアクセスを行いデータを取得しています。App Router を利用しているためデフォルトではすべてのコンポーネントは Server Components としてサーバ上で処理が行われます。サーバ上で処理が行われるため prisma を利用したコードをそのまま記述することができます。


import prisma from '@/lib/prisma';
import { Todo } from "@prisma/client";

const Page = async () => {
  const todos: Todo[]  = await prisma.todo.findMany();

  return (
    <div className="m-8">
      <h1 className="text-xl font-bold">Todo一覧</h1>
      <ul className="mt-8">
        {todos.map((todo) => (
          <li key={todo.id}>{todo.name}</li>
        ))}
      </ul>
    </div>
  );
};

export default Page;

開発サーバを起動して Todo 一覧を確認する前に layout.tsx ファイルで import している globals.css ファイルの CSS の設定を解除するために Tailwind CSS のディレクティブ以外を削除しておきます。削除後の globals.css は下記の通りです。


@tailwind base;
@tailwind components;
@tailwind utilities;

globals.css の更新後、“npm run dev” コマンドを実行して開発サーバを起動すると以下の画面が表示されます。


% npm run dev

> nextjs-14-server-actions@0.1.0 dev
> next dev

   ▲ Next.js 14.0.2
   - Local:        http://localhost:3000
   - Environments: .env
SQLiteデータベースに保存された情報を表示
SQLiteデータベースに保存された情報を表示

Server Actions を利用しない場合のフォーム処理

Server Actions の動作確認を行う前に Server Actions を利用しない場合の方法を確認します。本文書では API エンドポイントを作成して fetch 関数で POST リクエストを送信してデータを保存します。Server Actions を利用しない場合の設定には useState Hook, useRef Hook, Hooks を利用しないなど複数の方法があります。本文書ではその中から useState Hook を利用した方法で動作確認を行います。その他の方法については以下の文書の前半部分を参考にしてください。

useState を利用した方法

useState を含め React の Hoos をコンポーネントで利用する場合には Next.js では Client Components として設定する必要があります。Client Components として利用する場合にはコードの先頭に”use client”を追加します。

フォーム部分は Server Components である todos/pages.tsx ファイルと別ファイルとして作成するためプロジェクトディレクトリの直下に components ディレクトリを作成してその下に Form.tsx ファイルを作成します。


'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';

const Form = () => {
  const router = useRouter();
  const [name, setName] = useState('');

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setName(e.target.value);
  };

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    await fetch('/api/todos', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json', //
      },
      body: JSON.stringify({ name }),
    });
    setName('');
    router.push('/todos');
  };

  return (
    <form className="flex items-center mt-4" onSubmit={handleSubmit}>
      <label htmlFor="name">Name:</label>
      <input
        id="name"
        name="name"
        value={name}
        onChange={handleChange}
        className="border mx-2 p-1"
      />
      <button
        type="submit"
        className="bg-blue-600 px-2 py-1 rounded-lg text-sm text-white"
      >
        Add Todo
      </button>
    </form>
  );
};

export default Form;

作成した Form コンポーネントを todos/page.tsx ファイルで import しています。


import Form from '@/components/Form';
import prisma from '@/lib/prisma';

const Page = async () => {
  const todos = await prisma.todo.findMany();

  return (
    <div className="m-8">
      <h1 className="text-xl font-bold">Todo一覧</h1>
      <ul className="mt-8">
        {todos.map((todo) => (
          <li key={todo.id}>{todo.name}</li>
        ))}
      </ul>
      <Form />
    </div>
  );
};

export default Page;

ブラウザから確認すると以下の画面が表示されます。

Todoの入力フォームの表示
Todoの入力フォームの表示

fetch 関数のリクエスト先には/api/todos を設定しているので API エンドポイントとして Route Handlers の設定を行います。app ディレクトリの下に api ディレクトリを作成してその下に todos ディレクトリを作成してその下に route.ts ファイルを作成します。routes.ts ファイルを作成すると Next.js が自動でルーティングを設定してくれるので/api/todos の API エンドポイントとして利用することができます。Route Handlers はサーバ上で処理が行われるので Prisma を経由してデータベースを操作することができます。下記のコードでは POST リクエストから送信されてきたデータを受け取り、データベースに保存しています。


export async function POST(request: Request) {
  const { name } = await request.json();
  await prisma.todo.create({ data: { name } });
  return Response.json({ message: 'success' });
}

入力フォームに”Bun”を入力して”Add Todo”ボタンをクリックしてください。Todo 一覧に入力したデータが表示されます。表示されない場合はブラウザのリロードを行ってください。

データ追加後のTodo一覧
データ追加後のTodo一覧

Server Actions を利用しない場合の動作確認を行うことができました。

Server Actions の設定

Server Actions の設定は以下の 2 つの方法で行うことができます。

  • Server Components の中で設定
  • Server Actions をまとめた actions ファイルを作成してそのファイルの中で設定(Server Components, Client Components から利用可能)

それぞれの場合にどのように設定するのか確認していきます。

Server Components の場合

Server Components である todos/page.tsx ファイルに入力フォームを追加します。


import prisma from '@/lib/prisma';

const Page = async () => {
  const todos = await prisma.todo.findMany();

  return (
    <div className="m-8">
      <h1 className="text-xl font-bold">Todo一覧</h1>
      <ul className="mt-8">
        {todos.map((todo) => (
          <li key={todo.id}>{todo.name}</li>
        ))}
      </ul>
      <form className="flex items-center mt-4">
        <label htmlFor="name">Name:</label>
        <input type="text" name="name" className="border mx-2 p-1" />
        <button
          type="submit"
          className="bg-blue-600 px-2 py-1 rounded-lg text-sm text-white"
        >
          Add Todo
        </button>
      </form>
    </div>
  );
};

export default Page;

入力フォームが表示されますが入力を行い”Add Todo”ボタンをクリックするとページのリロードが行われます。データを保存する処理は何も行っていないので Todo 一覧に変化はありません。

データ追加後のTodo一覧
ブラウザ上での変化なし

Server Actions の設定を行うため form タグの action 属性に関数を設定します。action 属性に指定した addTodo 関数は非同期関数として作成して”use server”を追加する必要があります。


import prisma from '@/lib/prisma';

const Page = async () => {
  const todos = await prisma.todo.findMany();

  const addTodo = async () => {
    'use server';
    console.log('click');
  };

  return (
    <div className="m-8">
      <h1 className="text-xl font-bold">Todo一覧</h1>
      <ul className="mt-8">
        {todos.map((todo) => (
          <li key={todo.id}>{todo.name}</li>
        ))}
      </ul>
      <form className="flex items-center mt-4" action={addTodo}>
        <label htmlFor="name">Name:</label>
        <input type="text" name="name" className="border mx-2 p-1" />
        <button
          type="submit"
          className="bg-blue-600 px-2 py-1 rounded-lg text-sm text-white"
        >
          Add Todo
        </button>
      </form>
    </div>
  );
};

export default Page;

addTodo 関数の中に”use server”がない場合は Unhandled Runtime Error で以下のエラーが画面に表示されます。


Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server".

addTodo 関数 が非同期関数ではない場合は”Error: × Server actions must be async functions”と表示されます。

ここまでの設定で Server Actions の設定は完了しているので”Add Todo”ボタンをクリックしてください。先ほどのようにボタンを押してもページのリロードは行われず、“npm run dev”を実行したターミナルに”click”の文字列が表示されます。このことからもサーバ側で処理が行われていることがわかります。

クライアント(ブラウザ)側で実行された場合はブラウザのコンソールに”click”が表示されます。
fukidashi

form タグの props の action に関数を設定した場合は関数の第一引数にフォームの form data が含まれています。そのため addTodo 関数の引数から入力フォームに入力した値を受け取ることができます。

form についての説明は Next.js のドキュメントよりも React のドキュメントのほうが個人的にはわかりやすい気がするのでぜひ参考にしてください。https://react.dev/reference/react-dom/components/form
fukidashi

addTodo 関数はサーバ側での処理なので Prisma を介してデータベースを操作することができます。


const addTodo = async (data: FormData) => {
  'use server';
  const name = data.get('name') as string;
  await prisma.todo.create({ data: { name } });
};

入力フォームに文字列を入力して”Add Todo”ボタンをクリックしても画面には何も変化はありません。ブラウザのリロードボタンをクリックすると追加した文字列が表示されます。

入力した文字列が”Add Todo”ボタンをクリック後に即座にブラウザ上に反映させるため revalidatePath 関数を利用します。


import prisma from '@/lib/prisma';
import { revalidatePath } from 'next/cache';

const Page = async () => {
  const todos = await prisma.todo.findMany();

  const addTodo = async (data: FormData) => {
    'use server';
    const name = data.get('name') as string;
    await prisma.todo.create({ data: { name } });
    revalidatePath('/todos');
  };
  //略

revalidatePath の引数にページのパスを指定するとキャッシュしたデータを破棄して新たにデータを取得するため最新の情報がブラウザ上に表示されます。

revalidatePathを追加した後
revalidatePathを追加した後

Server Components 上で Server Actions を利用してデータを登録できることが確認できました。

addTodo 関数を定義せず直接 Server Actions のコードを記述することもできます。


import prisma from '@/lib/prisma';
import { revalidatePath } from 'next/cache';

const Page = async () => {
  const todos = await prisma.todo.findMany();

  return (
    <div className="m-8">
      <h1 className="text-xl font-bold">Todo一覧</h1>
      <ul className="mt-8">
        {todos.map((todo) => (
          <li key={todo.id}>{todo.name}</li>
        ))}
      </ul>
      <form
        className="flex items-center mt-4"
        action={async (data: FormData) => {
          'use server';
          const name = data.get('name') as string;
          await prisma.todo.create({ data: { name } });
          revalidatePath('/todos');
        }}
      >
        <label htmlFor="name">Name:</label>
        <input type="text" name="name" className="border mx-2 p-1" />
        <button
          type="submit"
          className="bg-blue-600 px-2 py-1 rounded-lg text-sm text-white"
        >
          Add Todo
        </button>
      </form>
    </div>
  );
};

export default Page;

actions ファイルを作成した場合

Server Components の場合

ここでは Server Actions を別ファイルとして作成して作成したファイルの中で定義するために lib ディレクトリに actions.ts ファイルを作成します。actions.ts ファイルの先頭に’use server’を追加することで関数の中で’use server’を追加する必要がなくなります。


'use server';
import { revalidatePath } from 'next/cache';

export const addTodo = async (data: FormData) => {
  const name = data.get('name') as string;
  await prisma.todo.create({ data: { name } });
  revalidatePath('/todos');
};

actions.ts ファイルで定義した addTodo 関数を page.tsx ファイルで import して設定を行います。


import prisma from '@/lib/prisma';
import { addTodo } from '@/lib/actions';

const Page = async () => {
  const todos = await prisma.todo.findMany();

  return (
    <div className="m-8">
      <h1 className="text-xl font-bold">Todo一覧</h1>
      <ul className="mt-8">
        {todos.map((todo) => (
          <li key={todo.id}>{todo.name}</li>
        ))}
      </ul>
      <form className="flex items-center mt-4" action={addTodo}>
        <label htmlFor="name">Name:</label>
        <input type="text" name="name" className="border mx-2 p-1" />
        <button
          type="submit"
          className="bg-blue-600 px-2 py-1 rounded-lg text-sm text-white"
        >
          Add Todo
        </button>
      </form>
    </div>
  );
};

export default Page;

入力フォームから文字列を入力して”Add Todo”ボタンをクリックすると入力した文字列が Todo の一覧が表示されます。

actions ファイルを作成して Server Actions を定義した場合でもデータを登録できることが確認できました。

Client Components の場合

Clinet Components からの Server Actions の実行方法を確認します。Form.tsx ファイルを下記のように更新します。先頭に’use client’が入っているので Client Components として動作します。import している addTodo に変更はありません。


'use client';

import { addTodo } from '@/lib/actions';

const Form = () => {
  return (
    <form
      className="flex items-center mt-4"
      action={addTodo}
    >
      <label htmlFor="name">Name:</label>
      <input id="name" name="name" className="border mx-2 p-1" />
      <button
        type="submit"
        className="bg-blue-600 px-2 py-1 rounded-lg text-sm text-white"
      >
        Add Todo
      </button>
    </form>
  );
};

export default Form;

todos/page.tsx ファイルで作成した Form コンポーネントを import します。


import Form from '@/components/Form';
import prisma from '@/lib/prisma';

const Page = async () => {
  const todos = await prisma.todo.findMany();

  return (
    <div className="m-8">
      <h1 className="text-xl font-bold">Todo一覧</h1>
      <ul className="mt-8">
        {todos.map((todo) => (
          <li key={todo.id}>{todo.name}</li>
        ))}
      </ul>
      <Form />
    </div>
  );
};

export default Page;

入力フォームから文字列を入力して”Add Todo”ボタンをクリックすると入力した文字列が Todo の一覧が表示されます。

actions ファイルを作成して Server Actions を定義した場合は Client Components からも Server Actions が利用できることが確認できました。

useState と一緒に利用できる?

“Server Actions を利用しない場合のフォーム処理”として useState を利用しました。Client Components からでも Server Actions は利用できることがわかったので useState に保存されたデータを Server Actions で利用できるのか確認してみましょう。

Form.tsx ファイルは下記のように更新します。handleSubmit 関数の中で Server Actions の addTodo を利用しています。引数には useState で定義した name を指定しています。


'use client';

import { useState } from 'react';
import { addTodo } from '@/lib/actions';

const Form = () => {
  const [name, setName] = useState('');

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setName(e.target.value);
  };

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    await addTodo(name);
  };

  return (
    <form className="flex items-center mt-4" onSubmit={handleSubmit}>
      <label htmlFor="name">Name:</label>
      <input
        id="name"
        name="name"
        value={name}
        onChange={handleChange}
        className="border mx-2 p-1"
      />
      <button
        type="submit"
        className="bg-blue-600 px-2 py-1 rounded-lg text-sm text-white"
      >
        Add Todo
      </button>
    </form>
  );
};

export default Form;

actions.ts ファイルの addTodo 関数の引数を変更します。


export const addTodo = async (name: string) => {
  await prisma.todo.create({ data: { name } });
  revalidatePath('/todos');
};
入力フォームから文字列を入力して”Add Todo”ボタンをクリックすると入力した文字列が Todo の一覧が表示されます。

Client Components の動作確認はできたので todos/page.tsx ファイルは元の状態に戻します。


import prisma from '@/lib/prisma';
import { addTodo } from '@/lib/actions';

const Page = async () => {
  const todos = await prisma.todo.findMany();

  return (
    <div className="m-8">
      <h1 className="text-xl font-bold">Todo一覧</h1>
      <ul className="mt-8">
        {todos.map((todo) => (
          <li key={todo.id}>{todo.name}</li>
        ))}
      </ul>
      <form className="flex items-center mt-4" action={addTodo}>
        <label htmlFor="name">Name:</label>
        <input type="text" name="name" className="border mx-2 p-1" />
        <button
          type="submit"
          className="bg-blue-600 px-2 py-1 rounded-lg text-sm text-white"
        >
          Add Todo
        </button>
      </form>
    </div>
  );
};

export default Page;

Server Actions によるデータ削除

Todo の文字列の右側に削除ボタンを追加します。


//略
<h1 className="text-xl font-bold">Todo一覧</h1>
<ul className="mt-8">
  {todos.map((todo) => (
    <li key={todo.id} className="flex items-center space-x-2">
      <span>{todo.name}</span>
      <button className="bg-red-500 px-2 py-1 rounded-lg text-sm text-white">
        削除
      </button>
    </li>
  ))}
</ul>
//略

ボタンは表示されましたがボタンをクリックしても何も変化はありません。

削除ボタンの追加
削除ボタンの追加

button タグを form タグで囲み action 属性には deleteTodo 関数を追加します。


<h1 className="text-xl font-bold">Todo一覧</h1>
<ul className="mt-8">
  {todos.map((todo) => (
    <li key={todo.id} className="flex items-center space-x-2">
      <span>{todo.name}</span>
      <form action={deleteTodo}>
        <button className="bg-red-500 px-2 py-1 rounded-lg text-sm text-white">
          削除
        </button>
      </form>
    </li>
  ))}
</ul>

追加した deleteTodo 関数は actions.ts ファイルで定義します。


export const deleteTodo = async () => {
  console.log('delete');
};

deleteTodo 関数を action.ts ファイルに追加は todos/page.tsx ファイルで import を行います。


import { addTodo, deleteTodo } from '@/lib/actions';

削除ボタンをクリックするとサーバ上で処理が行われるので”npm run dev”コマンドを実行したターミナルに”delete”の文字列が表示されます。

削除ボタンをクリックした文字列を削除するためには削除するデータをデータベース内で特定するために id の情報が必要となるため deleteTodo 関数の引数で id を受け取れるように設定を行います。


export const deleteTodo = async (id: number) => {
  await prisma.todo.delete({
    where: {
      id,
    },
  });
  revalidatePath('/todos');
};

deleteTodo 関数に引数を設定しましたがどのように id を渡せばいいのでしょうか。

bind を利用した場合

Server Actions に引数を渡したい場合にbindを利用することができます。bind を利用することで Server Actions 関数の第一引数に bind の第二引数で指定した値を渡すことができます。

id を bind するために components ディレクトリに delete-button.tsx ファイルを作成して props で渡された id を bind を利用して deleteTodo 関数に id を渡します。


import { deleteTodo } from '@/lib/actions';

const DeleteButton = ({ id }: { id: number }) => {
  const deleteTodoWithId = deleteTodo.bind(null, id);

  return (
    <form action={deleteTodoWithId}>
      <button className="bg-red-500 px-2 py-1 rounded-lg text-sm text-white">
        削除
      </button>
    </form>
  );
};

export default DeleteButton;

作成した delete-button.tsx ファイルを page.tsx ファイルで import します。


import prisma from '@/lib/prisma';
import { addTodo } from '@/lib/actions';
import DeleteButton from '@/components/delete-button';

const Page = async () => {
  const todos = await prisma.todo.findMany();

  return (
    <div className="m-8">
      <h1 className="text-xl font-bold">Todo一覧</h1>
      <ul className="mt-8">
        {todos.map((todo) => (
          <li key={todo.id} className="flex items-center space-x-2">
            <span>{todo.name}</span>
            <DeleteButton id={todo.id} />
          </li>
        ))}
      </ul>
      <form className="flex items-center mt-4" action={addTodo}>
        <label htmlFor="name">Name:</label>
        <input type="text" name="name" className="border mx-2 p-1" />
        <button
          type="submit"
          className="bg-blue-600 px-2 py-1 rounded-lg text-sm text-white"
        >
          Add Todo
        </button>
      </form>
    </div>
  );
};

export default Page;

ファイルの更新後に削除ボタンをクリックすると削除ボタンに対応する文字列が Todo 一覧から削除されます。

bind を利用しない場合(1)

bind を利用しない場合は input 要素で type を hidden に設定した id を deleteTodo に渡すことができます。


import prisma from '@/lib/prisma';
import { addTodo, deleteTodo } from '@/lib/actions';

const Page = async () => {
  const todos = await prisma.todo.findMany();

  return (
    <div className="m-8">
      <h1 className="text-xl font-bold">Todo一覧</h1>
      <ul className="mt-8">
        {todos.map((todo) => (
          <li key={todo.id} className="flex items-center space-x-2">
            <span>{todo.name}</span>
            <form action={deleteTodo}>
              <input type="hidden" name="id" value={todo.id} />
              <button className="bg-red-500 px-2 py-1 rounded-lg text-sm text-white">
                削除
              </button>
            </form>
          </li>
        ))}
      </ul>
      //略

deleteTodo では data に含まれる id を取り出します。


export const deleteTodo = async (data: FormData) => {
  const id = data.get('id') as string;
  await prisma.todo.delete({
    where: {
      id: Number(id),
    },
  });
  revalidatePath('/todos');
};

削除ボタンをクリックすると削除ボタンに対応した文字列が Todo 一覧から削除されます。

bind を利用しない場合(2)

form タグの props の action に Server Actions を下記のように利用することでも id を取得することができます。


import prisma from '@/lib/prisma';
import { addTodo, deleteTodo } from '@/lib/actions';
import { revalidatePath } from 'next/cache';

const Page = async () => {
  const todos = await prisma.todo.findMany();

  return (
    <div className="m-8">
      <h1 className="text-xl font-bold">Todo一覧</h1>
      <ul className="mt-8">
        {todos.map((todo) => (
          <li key={todo.id} className="flex items-center space-x-2">
            <span>{todo.name}</span>
            <form
              action={async () => {
                'use server';
                await deleteTodo(todo.id);
                revalidatePath('/todos');
              }}
            >
              <button className="bg-red-500 px-2 py-1 rounded-lg text-sm text-white">
                削除
              </button>
            </form>
          </li>
        ))}
      </ul>
//略

props の action に設定した関数の引数には何も設定していませんが引数には form data が渡されています。
fukidashi

action.ts ファイルの deleteTodo は下記となります。


export const deleteTodo = async (id: number) => {
  await prisma.todo.delete({
    where: {
      id,
    },
  });
};

削除ボタンをクリックすると削除ボタンに対応した文字列が Todo 一覧から削除されます。

formAction を利用した場合

ここまでは form タグの props の action に Server Actions を設定していましたが button 要素に formAction props に追加して Server Actions を設定することもできます。


import prisma from '@/lib/prisma';
import { addTodo, deleteTodo } from '@/lib/actions';

const Page = async () => {
  const todos = await prisma.todo.findMany();

  return (
    <div className="m-8">
      <h1 className="text-xl font-bold">Todo一覧</h1>
      <ul className="mt-8">
        {todos.map((todo) => (
          <li key={todo.id} className="flex items-center space-x-2">
            <span>{todo.name}</span>
            <form>
              <input type="hidden" name="id" value={todo.id} />
              <button
                formAction={deleteTodo}
                className="bg-red-500 px-2 py-1 rounded-lg text-sm text-white"
              >
                削除
              </button>
            </form>
          </li>
        ))}
      </ul>
      //略

export const deleteTodo = async (data: FormData) => {
  const id = data.get('id') as string;
  await prisma.todo.delete({
    where: {
      id: Number(id),
    },
  });
  revalidatePath('/todos');
};
form タグは必要です。
fukidashi

ここまでの動作確認で基本的な Server Actions の理解と設定方法について理解することができました。

CRUD の設定

先ほどは todos/page.tsx ファイル上で Todo 一覧の表示、追加、削除を行いました。ここでは一覧表示ページ、詳細ページ、追加ページ、更新ページと別ページを作成して CRUD(Create, Read, Update, Delete)の動作確認を行っていきます。

一覧ページの作成

一覧ページについてはこれまで通り page.tsx ファイルで行います。


import prisma from '@/lib/prisma';

const Page = async () => {
  const todos = await prisma.todo.findMany();

  return (
    <div className="m-8">
      <h1 className="text-xl font-bold">Todo一覧</h1>
      <ul className="mt-8">
        {todos.map((todo) => (
          <li key={todo.id}>
            <span>{todo.name}</span>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default Page;
データ追加後のTodo一覧
Todo一覧

詳細ページへのリンクボタンを追加します。


import prisma from '@/lib/prisma';
import Link from 'next/link';

const Page = async () => {
  const todos = await prisma.todo.findMany();

  return (
    <div className="m-8">
      <h1 className="text-xl font-bold">Todo一覧</h1>
      <ul className="mt-8">
        {todos.map((todo) => (
          <li key={todo.id} className="flex items-center space-x-2">
            <span>{todo.name}</span>
            <Link href={`/todos/${todo.id}`}>詳細</Link>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default Page;

ブラウザで確認すると詳細ページへのリンクが表示されます。

詳細ページへのリンク
詳細ページへのリンク

詳細の文字列をクリックしてページの移動(todos/id)を行うことができましたがページが存在しないため”404 Not Found”ページが表示されます。

404 Not Foundエラー
404 Not Foundエラー

詳細ページの作成

詳細ページに含まれる URL の id は動的に値が変わります。URL が動的に変わる値の場合にはブラケットを利用してディレクトリを作成します。todos ディレクトリの下に[id]ディレクトリを作成してその下に page.tsx ファイルを作成します。 id の値は params の中に含まれます。


export default function Page({ params }: { params: { id: string } }) {
  return (
    <div className="m-8">
      <h1 className="text-xl font-bold">Todo詳細</h1>
      <div>Todo ID: {params.id}</div>
    </div>
  );
}

先ほどまでは”404 Not Found”エラーが表示されていましたが[id]/page.tsx ファイル作成後は id が表示されます。

詳細ページの表示
詳細ページの表示

id を利用してデータベースから Todo の情報を取得します。/todos ページに戻れるようにリンクも追加しています。


import Link from 'next/link';

export default async function Page({ params }: { params: { id: string } }) {
  const id = Number(params.id);
  const todo = await prisma.todo.findUnique({
    where: {
      id,
    },
  });
  return (
    <div className="m-8">
      <Link href="/todos">戻る</Link>
      <h1 className="text-xl font-bold">Todo詳細</h1>
      <div>Id: {todo?.id}</div>
      <div>名前: {todo?.name}</div>
      <div>
        完了: {todo?.isCompleted ? <span>完了</span> : <span>未完了</span>}
      </div>
    </div>
  );
}

ブラウザ上には Todo の情報と戻るリンクが表示されます。戻るをクリックすると Todo 一覧の/todos に戻ります。

戻るリンクを追加
戻るリンクを追加

追加ページの作成

追加ページを作成する前に todos/page.tsx ファイルに追加ページへのリンクボタンを追加します。追加ページの URL は/todos/create としています。


import prisma from '@/lib/prisma';
import Link from 'next/link';

const Page = async () => {
  const todos = await prisma.todo.findMany();

  return (
    <div className="m-8">
      <h1 className="text-xl font-bold">Todo一覧</h1>
      <Link
        href="/todos/create"
        className="bg-blue-600 px-2 py-1 rounded-lg text-sm text-white"
      >
        新規追加
      </Link>
      <ul className="mt-8">
        {todos.map((todo) => (
          <li key={todo.id} className="flex items-center space-x-2">
            <span>{todo.name}</span>
            <Link href={`/todos/${todo.id}`}>詳細</Link>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default Page;
新規追加ボタン追加
新規追加ボタン追加

Todo の追加ページの URL は/todos/create にしたため todos ディレクトリの下に create ディレクトリを作成してその下に page.tsx ファイルを作成します。


import { addTodo } from '@/lib/actions';
import Link from 'next/link';

const Page = async () => {
  return (
    <div className="m-8">
      <Link href="/todos">戻る</Link>
      <h1 className="text-xl font-bold">Todo追加</h1>
      <form className="flex items-center mt-4" action={addTodo}>
        <label htmlFor="name">Name:</label>
        <input type="text" name="name" className="border mx-2 p-1" />
        <button
          type="submit"
          className="bg-blue-600 px-2 py-1 rounded-lg text-sm text-white"
        >
          Add Todo
        </button>
      </form>
    </div>
  );
};

export default Page;
Todoの追加ページ
Todoの追加ページ

Todo を追加後に/todos ページにリダイレクトできるように action.ts ファイルの addTodo 関数を更新します。


'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

export const addTodo = async (data: FormData) => {
  const name = data.get('name') as string;
  await prisma.todo.create({ data: { name } });
  revalidatePath('/todos');
  redirect('/todos');
};

Todo の作成ページから Todo を追加するとリダイレクトが行われ Todo 一覧の中に入力した Todo が含まれていることを確認してください。

更新ページの作成

更新ページの URL は/todos/[id]/edit とします。

todos/page.tsx ファイルに更新ページへのリンクを設定します。


import prisma from '@/lib/prisma';
import Link from 'next/link';

const Page = async () => {
  const todos = await prisma.todo.findMany();

  return (
    <div className="m-8">
      <h1 className="text-xl font-bold">Todo一覧</h1>
      <Link
        href="/todos/create"
        className="bg-blue-600 px-2 py-1 rounded-lg text-sm text-white"
      >
        新規追加
      </Link>
      <ul className="mt-8">
        {todos.map((todo) => (
          <li key={todo.id} className="flex items-center space-x-2">
            <span>{todo.name}</span>
            <Link href={`/todos/${todo.id}`}>詳細</Link>
            <Link href={`/todos/${todo.id}/edit`}>更新</Link>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default Page;

ブラウザから/todos にアクセスすると更新ページへのリンクが表示されます。

更新ページへのリンク
更新ページへのリンク

リンクをクリックしてもページを作成していないので”404 Not Found”エラー画面が表示されます。

更新ページの URL は/todos/[id]/edit としたため 作成済みの todos/[id]ディレクトリの下に edit ディレクトリを作成して page.tsx ファイルを作成します。Sever Actions の updateTodo は後ほど作成しますが bind を利用して updateTodo の引数に id を渡しています。


import { updateTodo } from '@/lib/actions';
import Link from 'next/link';

export default async function Page({ params }: { params: { id: string } }) {
  const id = Number(params.id);
  const updateTodoWithId = updateTodo.bind(null, id);
  const todo = await prisma.todo.findUnique({
    where: {
      id,
    },
  });
  return (
    <div className="m-8">
      <Link href="/todos">戻る</Link>
      <h1 className="text-xl font-bold">Todo更新</h1>
      <form action={updateTodoWithId} className="mt-4">
        <div>
          <label htmlFor="name">Name:</label>
          <input
            type="text"
            name="name"
            className="border mx-2 p-1"
            defaultValue={todo?.name}
          />
        </div>
        <div>
          <input
            name="isCompleted"
            type="radio"
            value="true"
            defaultChecked={todo?.isCompleted === true}
          />
          <label htmlFor="isCompleted">完了</label>
        </div>
        <div>
          <input
            name="isCompleted"
            type="radio"
            value="false"
            defaultChecked={todo?.isCompleted === false}
          />
          <label htmlFor="isCompleted">未完了</label>
        </div>
        <button
          type="submit"
          className="mt-4 bg-blue-600 px-2 py-1 rounded-lg text-sm text-white"
        >
          Update Todo
        </button>
      </form>
    </div>
  );
}

actions.ts ファイルに updateTodo 関数を追加します。引数には bind で設定した id とフォームの data が渡されます。


export const updateTodo = async (id: number, data: FormData) => {
  const name = data.get('name') as string;
  const isCompleted = data.get('isCompleted') as string;
  await prisma.todo.update({
    where: {
      id,
    },
    data: {
      name,
      isCompleted: isCompleted === 'true' ? true : false,
    },
  });
  revalidatePath('/todos');
  redirect('/todos');
};

ブラウザで確認すると下記の画面が表示されます。

更新ページの画面
更新ページの画面

更新ページで名前または Todo の完了、未完了ボタンを切り替えて’Update Todo’ボタンをクリックすると一覧ページ(/todos)にリダイレクトされます。Name を更新した場合は反映された値が一覧ページで確認できます。Todo の完了、未完了については一覧ページで表示を行っていないため詳細ページで確認することができます。

削除ボタンの追加

削除ボタンについては components ディレクトリに delete-button.tsx ファイルを作成済みなのでそれを利用します。


import { deleteTodo } from '@/lib/actions';

const DeleteButton = ({ id }: { id: number }) => {
  const deleteTodoWithId = deleteTodo.bind(null, id);

  return (
    <form action={deleteTodoWithId}>
      <button className="bg-red-500 px-2 py-1 rounded-lg text-sm text-white">
        削除
      </button>
    </form>
  );
};

export default DeleteButton;

actions.ts ファイルの deleteTodo のコードは下記の通りです。


export const deleteTodo = async (id: number) => {
  await prisma.todo.delete({
    where: {
      id,
    },
  });
  revalidatePath('/todos');
};

todos/page.tsx ファイルで DeleteButton を import します。id props で todo.id を渡します。


import prisma from '@/lib/prisma';
import Link from 'next/link';
import DeleteButton from '@/components/delete-button';

const Page = async () => {
  const todos = await prisma.todo.findMany();

  return (
    <div className="m-8">
      <h1 className="text-xl font-bold">Todo一覧</h1>
      <Link
        href="/todos/create"
        className="bg-blue-600 px-2 py-1 rounded-lg text-sm text-white"
      >
        新規追加
      </Link>
      <ul className="mt-8">
        {todos.map((todo) => (
          <li key={todo.id} className="flex items-center space-x-2">
            <span>{todo.name}</span>
            <Link href={`/todos/${todo.id}`}>詳細</Link>
            <Link href={`/todos/${todo.id}/edit`}>更新</Link>
            <DeleteButton id={todo.id} />
          </li>
        ))}
      </ul>
    </div>
  );
};

export default Page;

削除ボタンが表示されるので削除ボタンを押すと対応する Todo の文字列が削除されることを確認してください。

削除ボタンを追加
削除ボタンを追加

Server Actions を利用した CRUD の動作確認を行うことができました。

エラーハンドリング

Server Actions でエラーが発生した場合の処理の方法について確認を行っていきます。

Sever Actions の addTodo 関数の中で意図的にエラーを throw させます。


export const addTodo = async (data: FormData) => {
  const name = data.get('name') as string;
  throw new Error('error');
  await prisma.todo.create({ data: { name } });
  revalidatePath('/todos');
  redirect('/todos');
};

ブラウザから/todos/create にアクセスしてフォームに入力後、“Add Todo”ボタンをクリックすると画面上には”Unhandled Runtime Error”が表示されます。

意図的なエラーのthrow
意図的なエラーのthrow

throw されたエラーを catch するために try catch を追加します。


export const addTodo = async (data: FormData) => {
  const name = data.get('name') as string;
  try {
    throw new Error('error');
    await prisma.todo.create({ data: { name } });
  } catch (e) {
    return {
      message: 'Failed to add',
    };
  }

  revalidatePath('/todos');
  redirect('/todos');
};

addTodo 関数を実行するとエラーが throw され try,catch によって message を含んだオブジェクトが戻されますが現在の設定では入力フォームに入力して”Add Todo”ボタンをクリックしても画面に何も変化はありません。

addTodo にエラーが発生した場合に戻される message を受け取り表示する仕組みが必要になります。Server Actions から戻されるエラーは React の useFormState Hook を利用して取得することができます。

useFormState Hook の設定

useFormState Hook を todos/create/page.tsx ファイルで設定します。useFormState の引数には Sever Actions の関数 と 初期値の initialState の設定を行います。initialState の初期値は message の値 を null に設定しています。この message に addTodo 関数から戻されるメッセージに保存されます。useFormState の戻り値は state と formAction で state には message が含まれており、formAction は form タグの action 属性に設定します。


import { useFormState } from 'react-dom';
import { addTodo } from '@/lib/actions';
import Link from 'next/link';
const initialState = {
  message: null,
};
const Page = async () => {
  const [state, formAction] = useFormState(addTodo, initialState);
  return (
    <div className="m-8">
      <Link href="/todos">戻る</Link>
      <h1 className="text-xl font-bold">Todo追加</h1>
      <form className="flex items-center mt-4" action={formAction}>
        <label htmlFor="name">Name:</label>
        <input type="text" name="name" className="border mx-2 p-1" />
        <button
          type="submit"
          className="bg-blue-600 px-2 py-1 rounded-lg text-sm text-white"
        >
          Add Todo
        </button>
      </form>
    </div>
  );
};

export default Page;

useFormState を追加後にブラウザで確認すると”ReactServerComponentsError”が発生します。useFormState を利用するためには Client Component を利用する必要があります。


You're importing a component that needs useFormState. It only works in a Client Component but none of its parents are marked with "use client", so they're Server Components by default.

form タグの中身部分のみ取り出します。取り出したコードは components ディレクトリの create-form.tsx ファイルに保存します。Client Component なので先頭には”use client”を設定して、エラーが発生した場合にはメッセージが state.message に保存されるので表示できるように設定を行っています。


'use client';

import { useFormState } from 'react-dom';
import { addTodo } from '@/lib/actions';

const initialState = {
  message: null,
};

const CreateForm = () => {
  const [state, formAction] = useFormState(addTodo, initialState);

  return (
    <form className="mt-4" action={formAction}>
      <label htmlFor="name">Name:</label>
      <input type="text" name="name" className="border mx-2 p-1" />
      <div className="text-red-600 font-bold my-2">{state?.message}</div>
      <button
        type="submit"
        className="bg-blue-600 px-2 py-1 rounded-lg text-sm text-white"
      >
        Add Todo
      </button>
    </form>
  );
};

export default CreateForm;

Server Actions に設定している addTodo の第一引数に prevState を設定する必要があります。


export const addTodo = async (prevState: any, data: FormData) => {
  const name = data.get('name') as string;
  try {
    throw new Error('error');
    await prisma.todo.create({ data: { name } });
  } catch (e) {
    return {
      message: 'Failed to add',
    };
  }
  revalidatePath('/todos');
  redirect('/todos');
};

todos/page.tsx ファイルでは作成した CreateForm コンポーネントを import します。


import CreateForm from '@/components/create-form';
import Link from 'next/link';

const Page = async () => {
  return (
    <div className="m-8">
      <Link href="/todos">戻る</Link>
      <h1 className="text-xl font-bold">Todo追加</h1>
      <CreateForm />
    </div>
  );
};

export default Page;

設定は完了したのでブラウザから/todos/create にアクセスして”Add Todo”ボタンをクリックします。addTodo 関数ではエラーを意図的に throw しているので message に設定した文字列がブラウザ上に表示されるようになります。

エラーの表示
エラーの表示

エラーメッセージが表示されることがわかったので addTodo 関数から throw new Error を削除します。


export const addTodo = async (prevState: any, data: FormData) => {
  const name = data.get('name') as string;
  try {
    await prisma.todo.create({ data: { name } });
  } catch (e) {
    return {
      message: 'Failed to add',
    };
  }
  revalidatePath('/todos');
  redirect('/todos');
};

Todo 追加ページから文字列を入力して”Add Todo”ボタンをクリックすると/todos にリダイレクトされ入力した文字列が一覧に追加されて表示されます。prisma を介したデータベースの処理に問題が発生した場合にエラーを表示できるようになりました。

Zod によるバリデーション

入力フォームのバリデーションを行いたい場合に必須項目の input 要素に reqired を設定することができますがより複雑なバリデーションを行いたい場合には バリデーションライブラリの Zod を利用することができます。

Zod を利用するためにはインストールを行います。


 % npm install zod

Zod によるバリデーションを行うためにスキーマの定義を行います。スキーマで name プロパティは 2 文字以上必要であることを定義しています。


const schema = z.object({
  name: z.string().min(2),
});

バリデーションを行う方法には parse メソッドと safeParse メソッドの 2 つがあります。parse メソッドではバリデーションに失敗した場合にエラーが throw されます。safeParse ではバリデーションが成功しかたどうかの値が bool 値として戻り値のオブジェクトの success プロパティに含まれています。ここでは safeParse を利用して success の値を分岐で利用してバリデーションに失敗した場合のみバリデーションエラーを戻すように設定を行います。

意図的にバリデーションに失敗する文字列を入れて safeParse メソッドの戻り値にどのような情報が含まれているか確認します。


'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';

const schema = z.object({
  name: z.string().min(2),
});

export const addTodo = async (prevState: any, data: FormData) => {
  const name = data.get('name') as string;

  const validatedFields = schema.safeParse({
    name,
  });

  console.log(JSON.stringify(validatedFields, null, 2));

  try {
    await prisma.todo.create({ data: { name } });
  } catch (e) {
    return {
      message: 'Failed to add',
    };
  }
  revalidatePath('/todos');
  redirect('/todos');
};

フォームに”A”の文字列を入力した”Add Todo”ボタンをクリックすると npm run dev コマンドを実行したターミナルには以下の情報が表示されます。


{
  "success": false,
  "error": {
    "issues": [
      {
        "code": "too_small",
        "minimum": 2,
        "type": "string",
        "inclusive": true,
        "exact": false,
        "message": "String must contain at least 2 character(s)",
        "path": [
          "name"
        ]
      }
    ],
    "name": "ZodError"
  }
}

error プロパティの中にはいろいろな情報が含まれていますが欲しい情報はプロパティの名前(ここでは name)とエラーメッセージです。プロパティ名とエラーメッセージは以下のコードを利用して取得することができます。エラーメッセージは配列に含まれています。


validatedFields.error.flatten().fieldErrors

//表示せれるエラーの例
{ name: [ 'String must contain at least 2 character(s)' ] }

addTodo 関数ではバリデーションに失敗した場合のみバリデーションのエラーメッセージを戻るように設定を行います。


export const addTodo = async (prevState: any, data: FormData) => {
  const name = data.get('name') as string;

  const validatedFields = schema.safeParse({
    name,
  });

  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    };
  }

  try {
    await prisma.todo.create({ data: { name } });
  } catch (e) {
    return {
      message: 'Failed to add',
    };
  }
  revalidatePath('/todos');
  redirect('/todos');
};

createForm では initialState にバリデーションエラーを保存する errors を追加してバリデーションのエラーが発生した場合のみ addTodo 関数から戻されるエラーメッセージを表示できるように設定します。


'use client';

import { useFormState } from 'react-dom';
import { addTodo } from '@/lib/actions';

const initialState = {
  message: null,
  errors: {},
};

const CreateForm = () => {
  const [state, formAction] = useFormState(addTodo, initialState);

  return (
    <form className="mt-4" action={formAction}>
      <label htmlFor="name">Name:</label>
      <input type="text" name="name" className="border mx-2 p-1" />
      {state?.message && (
        <div className="text-red-600 font-bold my-2">{state?.message}</div>
      )}
      {state?.errors?.name &&
        state.errors.name.map((error: string) => (
          <div className="text-red-600 font-bold my-2" key={error}>
            {error}
          </div>
        ))}
      <button
        type="submit"
        className="bg-blue-600 px-2 py-1 rounded-lg text-sm text-white"
      >
        Add Todo
      </button>
    </form>
  );
};

export default CreateForm;

動作確認を行うためにフォームに 1 文字のみ入力して”Add Todo”ボタンをクリックすると Zod のバリデーションエラーのメッセージがブラウザ上に表示されます。

バリデーションのエラーメッセージ表示
バリデーションのエラーメッセージ表示

2 文字以上の文字列の場合は/todos リダイレクトが行われ入力した文字列が含まれる Todo 一覧が表示されます。

Server Actions でのバリデーションを含めたエラーのハンドリングについても確認することができました。

useFormStatus の設定

useFormStatus は Server Actions の処理がサーバ上で行われている間の Loading State として利用することができます。useFormStatus を利用することでサーバ上での処理に時間がかかっている場合に ボタンの複数回のクリックを防ぐことができます。

動作確認のため意図的に Server Actions の addTodo 関数の中で遅延処理を追加します。データをデータベースに保存する前に 2 秒間遅延させます。


try {
  await new Promise((resolve) => setTimeout(resolve, 2000));
  await prisma.todo.create({ data: { name } });
} catch (e) {
  return {
    message: 'Failed to add',
  };
}

この状態で追加ページから文字列を入力して”Add Todo”ボタンを複数回クリックしてください。サーバ側での処理が完了するまで何度でもボタンをクリックすることができ、処理が完了後 Todo 一覧にリダイレクトされるとクリックした回数分データが追加表示されます。

useFormStatus を利用して複数のクリックができないように設定を行っていきます。create-form.tsx ファイルで useFormStatus Hook を”react-dom”から import して 戻されるオブジェクトの中から pendig の値 を取り出します。取り出した pending を button 要素の disabled 属性に 設定し、pending の値が true の場合はボタンの opacity を 50 にするスタイル設定を行います。


'use client';

import { useFormState, useFormStatus } from 'react-dom';
import { addTodo } from '@/lib/actions';

const initialState = {
  message: null,
  errors: {},
};

const CreateForm = () => {
  const { pending } = useFormStatus();
  const [state, formAction] = useFormState(addTodo, initialState);

  return (
    <form className="mt-4" action={formAction}>
      <label htmlFor="name">Name:</label>
      <input type="text" name="name" className="border mx-2 p-1" />
      {state?.message && (
        <div className="text-red-600 font-bold my-2">{state?.message}</div>
      )}
      {state?.errors?.name &&
        state.errors.name.map((error: string) => (
          <div className="text-red-600 font-bold my-2" key={error}>
            {error}
          </div>
        ))}
      <button
        type="submit"
        disabled={pending}
        className={`bg-blue-600 px-2 py-1 rounded-lg text-sm text-white  ${
          pending ? 'opacity-50' : ''
        }`}
      >
        Add Todo
      </button>
    </form>
  );
};

export default CreateForm;

設定は完了しましたが上記の設定では useFormStatus は動作しません。useFormStatus を動作させるためには form 要素の子要素の中で設定を行う必要があります。components ディレクトリに submit-button.tsx ファイルを作成して以下のコードを記述します。


'use client';

import { useFormStatus } from 'react-dom';

export function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button
      type="submit"
      disabled={pending}
      className={`bg-blue-600 px-2 py-1 rounded-lg text-sm text-white  ${
        pending ? 'opacity-50' : ''
      }`}
    >
      Add Todoa
    </button>
  );
}

作成した submit-button を create-form.tsx ファイルで import します。


'use client';

import { useFormState, useFormStatus } from 'react-dom';
import { addTodo } from '@/lib/actions';
import { SubmitButton } from './submit-button';

const initialState = {
  message: null,
  errors: {},
};

const CreateForm = () => {
  const [state, formAction] = useFormState(addTodo, initialState);

  return (
    <form className="mt-4" action={formAction}>
      <label htmlFor="name">Name:</label>
      <input type="text" name="name" className="border mx-2 p-1" />
      {state?.message && (
        <div className="text-red-600 font-bold my-2">{state?.message}</div>
      )}
      {state?.errors?.name &&
        state.errors.name.map((error: string) => (
          <div className="text-red-600 font-bold my-2" key={error}>
            {error}
          </div>
        ))}
      <SubmitButton />
    </form>
  );
};

export default CreateForm;

“Add Todo”ボタンをクリックすると button 要素が disabled となり、“Add Todo”ボタンがクリックできなくなります。useFormStatus の利用方法を確認することができました。

button要素がdisabled
button要素がdisabled