Next.js が 14 にバージョンアップされ Server Actions が Stable になりました。本書は Server Actions の 13 のアルファの時の動作確認の内容ですが Next.js のバージョン 14.0.1 でも動作することを確認しました。
fukidashi

Server ActionsはNext.jsのバージョン13.4で新たに追加された機能です。Sever Actionsを利用することでfetch関数などを利用したクライアント(ブラウザ)コードを記述することなくサーバ上でデータ更新(作成、更新、削除)を行うことができます。データ更新に関係するコードがクライアント側で必要なくなるためクライアントがダウンロードするJavaScriptコードの削減につながります。さらにプログレッシブエンハンスメントなフォームを作成することができるためJavaScriptが利用できない環境でもフォームを利用することができます。

上記の説明だけでは理解するのが難しいと思うので本書ではシンプルなTodoアプリケーション上でServer Actionsを使ってCRUD(Create, Read, Update, Delete)の処理を実装していきます。

データを保存するデータベースには Prisma 経由で SQLite データベースを利用します。ルーティングは 13.4 からは本番環境で利用可能な App Router と Pages Router の 2 種類の方法から選択することができますが本文書では Sever Actions が利用できる App Router を利用します。

Server Actions は現在(2023 年 7 月)はアルファの機能なので本番環境では利用できない上、今後も変更が行われます。バージョンアップによる更新に合わせて本文書も更新していく予定です。

プロジェクトの作成

動作確認を行うためにNext.jsのプロジェクトを作成します。プロジェクトの作成は”npx create-next-app@latest”コマンドで行うことができます。プロジェクト名には任意の名前をつけることができるのでここではnext-13-actionとしています。コマンド実行後TypeScriptなど利用するか確認がありますがすべてデフォルトを選択します。特にApp Routerの選択は忘れずに”Yes”にしてください。


% npx create-next-app@latest next-13-action
✔ 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-13-action.

Using npm.

Initializing project with template: app-tw


Installing dependencies:
- react
- react-dom
- next
- typescript
- @types/react
- @types/node
- @types/react-dom
- tailwindcss
- postcss
- autoprefixer
- eslint
- eslint-config-next


added 350 packages, and audited 351 packages in 20s

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

found 0 vulnerabilities
Initialized a git repository.

Success! Created next-13-action at /Users/mac/Desktop/next-13-action

コマンドが完了後、作成されたプロジェクトディレクトリ next-13-action に移動して開発サーバを起動するため npm run dev コマンドを実行します。ブラウザから開発サーバが起動した http://localhost:3000 にアクセスするとデフォルトのトップ画面が表示されます。

トップページの表示
トップページの表示

インストールした各種パッケージのバージョンを確認するため package.json ファイルを確認しておきます。Next.js のバージョンは 13.4.10 であることがわかります。バージョンが異なると Server Actions の設定が変更になる可能性があるので注意してください。


{
  "name": "next-13-action",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@types/node": "20.4.2",
    "@types/react": "18.2.15",
    "@types/react-dom": "18.2.7",
    "autoprefixer": "10.4.14",
    "eslint": "8.45.0",
    "eslint-config-next": "13.4.10",
    "next": "13.4.10",
    "postcss": "8.4.26",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "tailwindcss": "3.3.3",
    "typescript": "5.1.6"
  }
}

デフォルトから src/app ディレクトリに存在する global.css ファイルの body タグへの CSS を削除しておきます。削除することでページ上にグラデーションが表示させないようにしています。

データベースの設定

データベースには SQLite データベースを利用します。SQLite を利用するためにアカウントを作成する必要もなく mac OS であればデフォルトからインストールされているため開発用に便利なデータベースです。

SQLite データベースを操作するため ORM ツールの Prisma を利用します。Prisma の設定ファイルでスキーマを定義することで SQL を利用することなくデータベースにテーブルを作成することができ、データベースの操作はオブジェクトのメソッドを通して行います。さらに定義したスキーマ情報は TypeScript の型にも利用することができます。

Prismaのインストール

npm コマンドを利用して Prisma のインストールを行います。インストールを行なった Prisma のバージョンは 5.0.0 です。


% npm install prisma --save-dev

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 16ms

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

//略

✔ Generated Prisma Client (4.14.0 | library) to ./node_modules/@prisma/client in 87ms

コマンドを実行すると.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の設定は完了です

Sever Actionsの有効化

Server Actionsは現在開発中なので利用するためにはnext.config.jsファイルでexperimental.serverActionsをtrueに設定する必要があります。


/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    serverActions: true,
  },
};

module.exports = nextConfig;

データの表示

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

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


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>
    </div>
  );
};

export default Page;

npm run devコマンドを実行して開発サーバを起動すると以下の画面が表示されます。

SQLiteデータベースに保存された情報を表示
SQLiteデータベースに保存された情報を表示

Sever Actionsの設定

Server Actonsを利用する方法には3つの方法が提供されています。

  • formタグのaction props
  • button, inputタグのformAction props
  • strartTransition Hook

言葉の説明だけでは理解するのが難しいと思うので上記の 3 つの方法を利用して CRUD(Create, Read, Update, Delete)を実装することで Server Actions の設定方法を理解していきます。

入力フォームの表示

React で入力フォームを作成する場合は onChange や onSubmit イベントを利用しますが Server Actions ではそれらのイベントは利用せず form タグと action props を利用してフォームを作成することができます。 Todo の name を入力するフォームは下記のように記述します。


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;

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

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

formタグのaction props

入力フォームが完成したので form タグに action props を設定します。action props には関数名を指定することができここでは action props に addTodo を指定して addTodo 関数を追加します。


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

  const addTodo = () => {
     console.log('add');
  };

  return (
    <div className="m-8">
   //略
      <form className="flex items-center mt-4" action={addTodo}>
        <label htmlFor="name">Name:</label>
    //略

追加すると”Unhandled Runtime Error”が発生してブラウザ上には以下のメッセージが表示されます。


Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server".
  <form className=... action={function} children=...>

App Router ではコンポーネントを Client Component として利用したい場合に’use client’の 1 行を追加します。Sever Actions では action props に指定した関数をサーバ上で実行させるため’use server’を追加する必要があります。

‘use server’は addTodo 関数の中に記述します。


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

‘use server’を追加後保存をすると次は”Server actions must be async function”のエラーメッセージが表示されます。

非同期関数にするため async を追加します。


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

エラーは解消するのでブラウザ上の”Add Todo”ボタンをクリックしてください。ブラウザのデベロッパーツールのコンソールではなく開発サーバを起動したターミナルに”add”の文字列が表示されます。ブラウザ上に表示されたボタンをクリックするとサーバ上で addTodo 関数が実行されるようになりました。

サーバ上で addTodo 関数を実行することができるようになったのでが次は入力フォームに入力した値を取得する必要があります。値を取得するために add 関数の引数に data を設定し、型には FormData を指定します。どのような値が含まれているか確認するために console.log を利用します。


const addTodo = async (data: FormData) => {
  'use server';
  console.log('data', data);
};

Nameのinput要素に文字列を入力して”Add Todo”ボタンをクリックすると開発サーバを起動したターミナルにフォームで入力した内容が表示されます。


data FormData {
  [Symbol(state)]: [
    {
      name: '$ACTION_ID_7a45c7ecd03fc212e9ef68b8ba6b8e2fe52c261e',
      value: ''
    },
    { name: 'todo', value: 'Learn Qwik1.0' }
  ]
}

ブラウザ上の入力フォームで入力した値をサーバ上で取得することができたので後はその値を利用してデータベースにデータを登録するたけです。Server Actions を利用することで非常にシンプルな方法で入力フォームが作成できることがわかりました。

入力した値は data の get メソッドの引数にプロパティ名である’name’を指定することで取得することができます。


const name = data.get('name') as string;

prismaのcreateメソッドを利用してデータベースにデータを登録します。


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

入力フォームから文字列を入力して”Add Todo”ボタンをクリックしても画面には何も変化がありません。しかし Prisma Studio で確認するとデータが追加されていることがわかります。ページのリロードを行うと登録したデータがブラウザ上に表示されます。

確認したようにサーバ側での処理がブラウザ側に反映されないため手動でページのリロードを行いました手動でリロードを行うことなく追加した内容をブラウザ上に反映させるために revalidatePath 関数を利用します。revalidatePath 関数を利用することで”Add Todo”ボタンをクリックしてサーバ上での処理が完了した後に追加した内容がブラウザ上に反映されるようになります。


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

formタグのaction propsを利用したServer Actionsでデータベースへのデータの登録が行えるようになりました。

buttonタグのformAction props

表示されているTodo一覧のTodoの横に削除ボタンを追加してボタンをクリックするとTodoが削除できるように設定を行っています。

buttonタグを追加してformAction propsにdeleteTodo関数を設定します。


<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"
        formAction={deleteTodo}
    >
        削除
    </button>
    </li>
))}
</ul>

deleteTodo 関数は addTodo 関数と同様に Server Actions の関数として利用するために非同期関数で’use server’を設定する必要があります。削除ボタンをクリックすると deleteTodo 関数が実行されるのか確認するために console.log(‘click’)を追加しています。


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

設定後、ブラウザ上に表示された”削除ボタン”をクリックしても開発サーバを起動したターミナルには何もメッセージは表示されません。formAction props を設定しただけでは動作しないことがわかります。

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

formタグでbutton要素をラップします。


<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
          className="bg-red-500 px-2 py-1 rounded-lg text-sm text-white"
          formAction={deleteTodo}
        >
          削除
        </button>
      </form>
    </li>
  ))}
</ul>

form タグを追加後に削除ボタンをクリックすると開発サーバを起動したターミナルに”click”が表示されるようになります。fromAction props を利用した場合にも form タグが必要であることがわかりました。

deleteTodo 関数が実行できるようになったので削除処理を追加します。削除を行うためには削除したい Todo の id が必要となります。Todo の id を deleteTodo 関数に渡すために input 要素を利用します。input 要素の type に hidden を設定します。


<form>
  <input type="hidden" name="id" value={todo.id} />
  <button
    className="bg-red-500 px-2 py-1 rounded-lg text-sm text-white"
    formAction={deleteTodo}
  >
    削除
  </button>
</form>

deleteTodoでidが取得するか確認します。


const deleteTodo = async (data: FormData) => {
  'use server';
  const id = data.get('id') as string;
  console.log(id);
};

設定後、削除ボタンをクリックするとクリックした Todo の id がターミナルに表示されます。

最後に prisma の delete メソッドを利用して todo テーブルの id と削除ボタンをクリックして取得した id が一致するデータの削除を行います。サーバ側で実行した処理が反映されるように revalidatePath も設定しておきます。


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

削除ボタンをクリックするとクリックした Todo が削除されます。button タグの formAction props を利用した Server Actions でデータベースの削除処理が行えるようになりました。

form タグの中に複数のボタンを設定してそれぞれに別の関数を設定した場合にも動作します。


<ul className="mt-8">
  {todos.map((todo) => (
    <li key={todo.id} className="flex items-center space-x-2">
      <form>
        <button
          className="bg-red-500 px-2 py-1 rounded-lg text-sm text-white"
          formAction={doneTodo}
        >
          完了
        </button>
        <span>{todo.name}</span>
        <input type="hidden" name="id" value={todo.id} />
        <button
          className="bg-red-500 px-2 py-1 rounded-lg text-sm text-white"
          formAction={deleteTodo}
        >
          削除
        </button>
      </form>
    </li>
  ))}
</ul>

const doneTodo = async (data: FormData) => {
  'use server';
  const id = data.get('id') as string;
  console.log(id);
};

完了ボタンを追加したので完了ボタンをクリックするとdoneTodoが実行されidがターミナルに表示されます。削除ボタンをクリックするとTodoが削除されます。

formAction propsを利用していてもformタグにaction propsを設定して別の関数を追加しsubmitボタンを追加することでfromActionとは異なる処理を行うことも可能です。

startTransitionを使った設定方法

startTransition は action props や formActions props を利用せず Server Actions を利用したい場合に使うことができます。ここでは startTransition を利用してデータの更新を行います。

ドキュメントに”Using startTransition disables the out-of-the-box Progressive Enhancement”と記載されています。startTransition を利用することでプログレッシブエンハンスメントが利用できなくなります。つまり JavaScript が有効でないと動作しません。
fukidashi

Todo が持つ isCompleted の値は Todo が完了しているかどうかを表す値です。isCompleted が true の場合は作成した Todo が完了していることをわかるように Todo 名の上に線を表示させるように Tailwind CSS の class である line-through の設定を行います。


<li
  key={todo.id}
  className={`flex items-center space-x-2 ${
    todo.isCompleted ? 'line-through' : ''
  }`}
>
  <span>{todo.name}</span>
  <form>
    <input type="hidden" name="id" value={todo.id} />
    <button
      className="bg-red-500 px-2 py-1 rounded-lg text-sm text-white"
      formAction={deleteTodo}
    >
      削除
    </button>
  </form>
</li>

default では isCompleted の値は false になっているためブラウザ上には変化がありません。Prisma Studio を利用して isCompleted の値を false から true に変更して線が表示させることを確認しておきます。

isCompletedの値によるline-throughの設定
isCompletedの値によるline-throughの設定

isCompletedの値をブラウザ上から変更できるようにinput要素でtype=”checkbox”を設定します。

ドキュメントには”You can use formAction prop to handle Form Actions on elements such as button, input type=“submit”, and input type=“image””と記載されており type=“checkbox”は含まれていませんが formAction を利用して設定を行ってみます。doneTodo 関数も新たに追加しています。


//略
const doneTodo = async () => {
  'use server';
  console.log('check');
};
//略
<form>
  <input
    type="checkbox"
    name="isCompleted"
    checked={todo.isCompleted}
    formAction={doneTodo}
  />
</form>

更新すると”Warning: You provided a checked prop to a form field without an onChange handler. This will render a read-only field. If the field should be mutable use defaultChecked. Otherwise, set either onChange or readOnly.“や”Warning: An input can only specify a formAction along with type=“submit” or type=“image””の警告が出て動作しません。

別の方法でtype=”checkbox”を利用する方法があるかもしれませんがここではaction props, formActions propsではなくstartTransitionを利用します。
fukidashi

formActions が利用できなかったので startTransition を利用して設定を行います。startTransition は useTransition Hook を利用します。


import { useTransition } from 'react';
//略
const Page = async () => {
  let [isPending, startTransition] = useTransition();
//略
<input
  onChange={() => startTransition(() => doneTodo())}
  checked={todo.isCompleted}
  type="checkbox"
/>

設定を行うと”Failed to compile”エラーが表示されます。


ReactServerComponentsError:

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

useTransition Hookを利用するためにはClient Componentを利用する必要があります。新たにtodosディレクトリの下にコンポーネントファイルのDoneTodo.tsxを作成します。


'use client';

import { useTransition } from 'react';

const DoneTodo = ({ isCompleted }: { isCompleted: boolean }) => {
  let [isPending, startTransition] = useTransition();

  const doneTodo = async () => {
    'use server';
    console.log('check');
  };

  return (
    <input
      onChange={() => startTransition(() => doneTodo())}
      checked={isCompleted}
      type="checkbox"
    />
  );
};

export default DoneTodo;

DoneTodo.tsx ファイルは Client Component として利用するため必ずコードの先頭に”use client”の 1 行を追加します。

props では isCompleted を受け取っています。

page.tsx ファイルで DoneTodo コンポーネントの import を行います。isCompleted props に todo.isCompleted を指定しています。


<ul className="mt-8">
  {todos.map((todo) => (
    <li
      key={todo.id}
      className={`flex items-center space-x-2 ${
        todo.isCompleted ? 'line-through' : ''
      }`}
    >
      <DoneTodo isCompleted={todo.isCompleted} />
      <span>{todo.name}</span>
      <form>
        <input type="hidden" name="id" value={todo.id} />
        <button
          className="bg-red-500 px-2 py-1 rounded-lg text-sm text-white"
          formAction={deleteTodo}
        >
          削除
        </button>
      </form>
    </li>
  ))}
</ul>

設定が完了するとエラーが発生し、’use server’はClient Componentで利用できないため別ファイルを作成してimportすることができると表示されます。


Error: 
  x "use server" functions are not allowed in client components. You can import them from a "use server" file instead.

todosディレクトリにactions.tsファイルを作成してdoneTodo関数を追加します。Server Actionsに関する関数を別ファイルにした場合にはファイルの先頭に’use server’を追加します。


'use server';

export async function doneTodo() {
  console.log('check');
}

DoneTodo.tsxファイルから作成したdoneTodo関数をimportします。


import { useTransition } from 'react';
import { doneTodo } from './actions';

const DoneTodo = ({ isCompleted }: { isCompleted: boolean }) => {
  let [isPending, startTransition] = useTransition();

  return (
    <input
      onChange={() => startTransition(() => doneTodo())}
      checked={isCompleted}
      type="checkbox"
    />
  );
};

export default DoneTodo;

ラーが解消されブラウザ上に表示されているチェックボックスをクリックすると開発サーバを起動したターミナルに”check”が表示されます。ブラウザ上のデベロッパーツールのコンソールに表示されることはないのでサーバ側で処理されていることがわかります。また Client Component での Server Actions の関数を利用する方法もわかりました。

チェックボックスをクリックしても isCompleted を更新する処理を実装していないため画面には何も変化はありません。

isCompleted を更新するためには Todo の id と現在の isCompleted が必要になるため DoneTodo コンポーネントに props として渡せるように設定を追加します。


<DoneTodo id={todo.id} isCompleted={todo.isCompleted} />

DoneTodo.tsx ファイルも追加された id props を受け取れるように変更を行います。actions から import した doneTodo 関数の引数を利用して props で受け取った id と isCompleted を渡します。


'use client';

import { useTransition } from 'react';
import { doneTodo } from './actions';

const DoneTodo = ({
  id,
  isCompleted,
}: {
  id: number;
  isCompleted: boolean;
}) => {
  let [isPending, startTransition] = useTransition();

  return (
    <input
      onChange={() => startTransition(() => doneTodo(id, isCompleted))}
      checked={isCompleted}
      type="checkbox"
    />
  );
};

export default DoneTodo;

actons.tsファイルのdoneTodoの中でprismaを利用してデータの更新を行います。


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

export async function doneTodo(id: number, isCompleted: boolean) {
  await prisma.todo.update({
    where: {
      id: Number(id),
    },
    data: {
      isCompleted: !isCompleted,
    },
  });
  revalidatePath('/posts');
}

設定完了後、チェックボックスをクリックして動作するか確認します。チェックがついている場合はチェックボタンをクリックするとチェックが消え、ついていない場合にはチェックボックスをクリックするとチェックがつきます。

チェックボックスの動作確認
チェックボックスの動作確認

formタグのaction props, buttonタグのformAction, startTransitionを利用した3つの方法でServer Actionsの設定方法を確認することができました。

strartTransitionを利用しない場合

startTransitionを利用せず、関数をpropsで渡すことでもstartTransitionを設定した場合と同様の設定を行うことができます。

page.txsファイルにdoneTodo関数を設定します。


async function doneTodo(id: number, isCompleted: boolean) {
  'use server';
  await prisma.todo.update({
    where: {
      id: Number(id),
    },
    data: {
      isCompleted: !isCompleted,
    },
  });
  revalidatePath('/posts');
}

設定したdoneTodo関数をDoneTodoのpropsに設定します。


<DoneTodo
  id={todo.id}
  isCompleted={todo.isCompleted}
  doneTodo={doneTodo}
/>

DoneTodo.tsxファイルでは受け取ったdoneTodo関数を利用するためonChangeイベントに指定します。


'use client';

const DoneTodo = ({
  id,
  isCompleted,
  doneTodo,
}: {
  id: number;
  isCompleted: boolean;
  doneTodo: (id: number, isCompleted: boolean) => Promise<void>;
}) => {
  return (
    <input
      onChange={() => doneTodo(id, isCompleted)}
      checked={isCompleted}
      type="checkbox"
    />
  );
};

startTransitionを利用しなくてもpropsで関数を渡すことでClient Compoentで渡した関数を利用することができます。

actionの関数をファイルにまとめる

startTransitionでdoneTodoファイルをactions.tsファイルに保存してimportして利用しましたがこれまで作成したすべてのactionの関数をactions.tsファイルにまとめることができます。


'use server';
import prisma from '@/lib/prisma';
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('/posts');
};

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

export const doneTodo = async (id: number, isCompleted: boolean) => {
  await prisma.todo.update({
    where: {
      id: Number(id),
    },
    data: {
      isCompleted: !isCompleted,
    },
  });
  revalidatePath('/posts');
};

page.tsxファイルではactions.tsファイルからactionの関数をimportして利用します。


import prisma from '@/lib/prisma';
import DoneTodo from './DoneTodo';
import { addTodo, deleteTodo, doneTodo } from './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 ${
              todo.isCompleted ? 'line-through' : ''
            }`}
          >
            <DoneTodo
              id={todo.id}
              isCompleted={todo.isCompleted}
              doneTodo={doneTodo}
            />
            <span>{todo.name}</span>
            <form>
              <input type="hidden" name="id" value={todo.id} />
              <button
                className="bg-red-500 px-2 py-1 rounded-lg text-sm text-white"
                formAction={deleteTodo}
              >
                削除
              </button>
            </form>
          </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;

1つのファイルにまとめてimportを行っても先ほどまでと同様に動作します。

フォームのinput要素のリセット

現在の設定は入力フォームに入力した値は”Add Todo”ボタンをクリックしてデータを登録してもそのまま入力値が残ったままの状態になります。

入力したフォームをリセットするための設定を行うために入力フォームを別のコンポーネントとし作成します。ファイル名は AddTodo.tsx として todos ディレクトリの中に作成します。AddTodo コンポーネントの中では useRef Hook を利用してフォームをリセットするため Client Component として作成する必要があります。action で指定する Server Actions の関数は別ファイルとして作成した action.ts ファイルから import しています。


'use client';

import { addTodo } from './actions';
import { useRef } from 'react';

const AddTodo = () => {
  const formRef = useRef<HTMLFormElement>(null);
  const add = async (data: FormData) => {
    await addTodo(data);
    if (formRef.current) formRef.current.reset();
  };
  return (
    <form className="flex items-center mt-4" action={add} ref={formRef}>
      <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>
  );
};

export default AddTodo;

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


import prisma from '@/lib/prisma';
import DoneTodo from './DoneTodo';
import AddTodo from './AddTodo';
import { deleteTodo, doneTodo } from './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 ${
              todo.isCompleted ? 'line-through' : ''
            }`}
          >
            <DoneTodo
              id={todo.id}
              isCompleted={todo.isCompleted}
              doneTodo={doneTodo}
            />
            <span>{todo.name}</span>
            <form>
              <input type="hidden" name="id" value={todo.id} />
              <button
                className="bg-red-500 px-2 py-1 rounded-lg text-sm text-white"
                formAction={deleteTodo}
              >
                削除
              </button>
            </form>
          </li>
        ))}
      </ul>
      <AddTodo />
    </div>
  );
};

export default Page;

設定後入力フォームで登録が完了した後はフォームがリセットされるため入力した値は input 要素から消えます。

Public フォルダへのファイルのアップロード

Server Actions を利用してファイルのアップロード方法を確認します。input 要素の type を file に設定します。選択した画像のデータは data.get(‘image’)で取り出すことができます。arrayBuffer メソッドと Buffer.from から戻される buffer を filePath に設定した public ディレクトリの下に保存しています。


import { writeFile } from 'fs/promises';

export default function Home() {
  const addImage = async (data: FormData) => {
    'use server';

    const image = data.get('image') as File;

    const arrayBuffer = await image.arrayBuffer();
    const buffer = Buffer.from(arrayBuffer);

    const filePath = `./public/${image.name}`;

    await writeFile(filePath, buffer);
  };
  return (
    <div>
      <h1>Server Actionsでファイルのアップロード</h1>
      <form className="flex items-center mt-4" action={addImage}>
        <input type="file" name="image" className="border mx-2 p-1" />
        <button
          type="submit"
          className="bg-blue-600 px-2 py-1 rounded-lg text-sm text-white"
        >
          Upload
        </button>
      </form>
    </div>
  );
}