🙄

【Next.js】Server Actionsを試してみた

2023/12/18に公開

この記事は?

Next.js 14 にて Server Actions がStableとなりました
Server ActionsによりAPIを介さずデータを参照できるのか試してみたいと思い簡単なWebアプリケーションを作ってみることにしました
また、この機会に気になっていた shadcn/uiPrismaBiome も簡単に触ってみたいと思います
試しながらで書いているためツッコミポイントも多くあるかと思いますが、そのような点を見つけられた方は是非コメントでご指摘ください

環境構築

  1. Dockerの準備
  2. Next.jsアプリケーションの作成
  3. Prismaの準備, スキーマ作成
  4. Biomeの準備, 設定
  5. shadcn/uiの準備

パッケージマネージャにはpnpmを使っているので、これ以外を使っている場合は適宜置き換えていただけると幸いです
IDEにはVSCodeを使用します

Docker準備

Dockerコンテナ上でNext.jsとMySQLが動くよう準備していきたいと思います

Dockerfile
FROM node:lts-alpine
ENV APP_ROOT /app
RUN apk update && \
    npm install -g pnpm && \
    mkdir -p $APP_ROOT
WORKDIR $APP_ROOT
COPY pnpm-lock.yaml $APP_ROOT
RUN pnpm fetch
ADD . $APP_ROOT
RUN pnpm install -r --offline
docker-compose.yaml
version: '3.9'

services:
  app:
    container_name: app
    platform: linux/x86_64
    build:
      context: .
    volumes:
      - ./:/app
    ports:
      - ${APP_PORT}:${APP_PORT}
    environment:
      MYSQL_HOST: ${MYSQL_HOST}
      MYSQL_USER: ${MYSQL_USER}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
      MYSQL_DATABASE: ${MYSQL_DATABASE}
      MYSQL_PORT: ${MYSQL_PORT}
    tty: true
    command: ash -c '/bin/ash'
    depends_on:
      - mysql

  mysql:
    container_name: mysql
    platform: linux/x86_64
    image: mysql:latest
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: ${MYSQL_DATABASE}
      MYSQL_USER: ${MYSQL_USER}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
      TZ: ${MYSQL_TIMEZONE}
    ports:
      - ${MYSQL_PORT}:${MYSQL_PORT}
    volumes:
      - ./db/my.cnf:/etc/mysql/conf.d/my.cnf
      - ./db/initdb.d:/docker-entrypoint-initdb.d
    restart: always
my.cnf
[mysqld]
character_set_server = utf8mb4
collation-server=utf8mb4_unicode_ci

[mysql]
default-character-set = utf8mb4

[client]
default-character-set = utf8mb4

Next.jsアプリケーションの作成

pnpm create next-app
What is your project named?  my-app
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

my-appの部分を自由に変えつつ、Biomeを使うためESLintNoしつつ作成を進めていきます

Prismaの準備、テーブル定義の作成

APIを介さずMySQLのデータを参照するためのORMとしてPrismaを使っていきたいと思います
下記の公式docを参考に作成を進めていきます
https://www.prisma.io/docs/orm/overview/databases/mysql
https://www.prisma.io/docs/getting-started/setup-prisma/start-from-scratch/relational-databases-typescript-mysql

// prismaクライアントの準備
pnpm add -D prisma
// 初期化処理
pnpm dlx prisma init --datasource-provider mysql

schema.prismaが生成されるので、作成するWebアプリケーションに必要なテーブルを記述していきます
今回はマンガを管理するWebアプリケーションを作りたいので、まずは簡単なマンガテーブル、著者テーブル、出版社テーブルを定義していきます

schema.prisma
// 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 = "mysql"
  url      = env("DATABASE_URL")
}

model i_book {
  id          String      @id @default(cuid())
  title       String
  authorId    String
  author      i_author    @relation(fields: [authorId], references: [id])
  publisherId String
  publisher   i_publisher @relation(fields: [publisherId], references: [id])
}

model i_publisher {
  id      String    @id @default(cuid())
  name    String
  books   i_book[]
}

model i_author {
  id      String    @id @default(cuid())
  name    String
  books   i_book[]
}

また、初期化時に.envに下記のようなPrismaの参照先情報が記述されるので、自身の環境に合わせて変更します

.env
# This was inserted by `prisma init`:
# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema

# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings

DATABASE_URL="mysql://johndoe:randompassword@localhost:3306/mydb"
migrateで怒られた場合

開発中、アプリケーション側が使用するmysqlユーザの実行権限が弱くmigrateが失敗することがあったため、予めmysqlコンテナへログインし権限を与えてあげると良いかもしれません

docker-compose exec mysql sh
sh-4.4# mysql -u root -p
mysql> grant create, alter, drop, references on *.* to `johndoe`@`%`;

johndoe部分は自身が使っているユーザへ適宜書き換えてください

Biomeの準備, 設定

下記の公式docを参考にインストールを行います
https://biomejs.dev/ja/guides/getting-started/#installation

pnpm add --save-dev --save-exact @biomejs/biome
pnpm dlx @biomejs/biome init

初期化処理を終えると下記ファイルが生成されるので、自身の好みで設定を変えていきます
どのようなルールを設定できるかは下記の公式docを参照してください
https://biomejs.dev/reference/configuration/

biome.json
{
  "$schema": "https://biomejs.dev/schemas/1.4.1/schema.json",
  "organizeImports": {
    "enabled": true
  },
  "linter": {
    "enabled": true,
    "ignore": [".next", "node_modules"],
    "rules": {
      "recommended": true
    }
  },
  "formatter": {
    "enabled": true,
    "ignore": [".next", "node_modules"],
    "indentStyle": "space",
    "indentWidth": 2,
    "lineWidth": 120
  },
  "javascript": {
    "parser": {
      "unsafeParameterDecoratorsEnabled": true
    },
    "formatter": {
      "quoteStyle": "single",
      "jsxQuoteStyle": "single",
      "semicolons": "asNeeded",
      "arrowParentheses": "asNeeded"
    }
  },
  "json": {
    "parser": { "allowComments": true },
    "formatter": {
      "enabled": true,
      "indentStyle": "space",
      "indentWidth": 2,
      "lineWidth": 120
    }
  }
}

VSCodeのエディタ上でファイル保存時に自動的にフォーマットさせたい場合は、下記を参考に拡張機能の導入しsetting.jsonをいじってみてください
https://biomejs.dev/ja/reference/vscode/

これでlintformatの準備は整ったので、package.jsonlintなどを下記のように書き加えて、pnpm lintなどで実行できるよう整えます

package.json
{
  "scripts": {
-   "lint": "next lint"
+   "lint": "pnpm dlx @biomejs/biome lint ./src",
+   "fmt": "pnpm dlx @biomejs/biome format ./src --write",
+   "check": "pnpm dlx @biomejs/biome check --apply ./src",
  },
}

shadcn/uiの準備

こちらも公式docを参考に進めていきます
https://ui.shadcn.com/docs/installation/next

pnpm add shadcn-ui
pnpm dlx shadcn-ui init
Would you like to use TypeScript (recommended)? no / yes
Which style would you like to use? › Default
Which color would you like to use as base color? › Slate
Where is your global CSS file? › › app/globals.css
Do you want to use CSS variables for colors? › no / yes
Where is your tailwind.config.js located? › tailwind.config.js
Configure the import alias for components: › @/components
Configure the import alias for utils: › @/lib/utils
Are you using React Server Components? › no / yes

shadcn/uiは自身が使いたいUIコンポーネントを都度pnpm addする必要があるためスクリプトへ追加するためのスクリプトを用意しておきます

package.json
{
  "scripts": {
+   "ui:add": "pnpm dlx shadcn-ui add"
  },
}

これで、例えばButtonを使いたい場合は下記のようにスクリプトを実行します

pnpm ui:add button
> pnpm dlx shadcn-ui add "button"

.../Library/pnpm/store/v3/tmp/dlx-70331  | +199 ++++++++++++++++++++
.../Library/pnpm/store/v3/tmp/dlx-70331  | Progress: resolved 199, reused 199, downloaded 0, added 199, done
✔ Done.

コーディング

ひとつひとつのコンポーネントを記載していくと記事が長くなるため抜粋して記載していきます

データ参照まわり

ディレクトリ構成

src
┣━ model/
┣━ repository/
┣━ service/
┗━ db.ts

db.tsは下記を参考にPrismaクライアントのインスタンスを生成しています
https://www.prisma.io/docs/orm/more/help-and-troubleshooting/help-articles/nextjs-prisma-client-dev-practices

repositoryでは、例えばBooksRepositoryを下記のように記述しています
ちなみにuse serverは省略できますが、わかりやすさのため敢えて付けています

BooksRepository.ts
'use server'

import { IBook } from '@/domain/model/Books'
import prisma from '../../db'

export async function getBooksWithAuthorAndPublisher() {
  const books = await prisma.i_book.findMany({
    include: {
      author: true,
      publisher: true,
    },
  })

  return books
}

export async function create(title: string, authorId: string, publisherId: string): Promise<IBook> {
  const created = await prisma.i_book.create({
    data: {
      title,
      authorId,
      publisherId,
    },
  })

  return created
}
includeについて

includeを指定することにより、当該テーブルとリレーションしているテーブルのレコードをオブジェクトに含めて返してくれるようです
https://www.prisma.io/docs/orm/prisma-client/queries/relation-queries

今回のテーブルだと、下記のようなオブジェクトを取得できます

(parameter) book: {
    author: {
        id: string;
        name: string;
    };
    publisher: {
        id: string;
        name: string;
    };
} & {
    id: string;
    title: string;
    authorId: string;
    publisherId: string;
}

viewコンポーネント

マンガ一覧

上記で定義したgetBooksWithAuthorAndPublisherを介してマンガ一覧を取得しテーブルUIで表示するコンポーネントです

必要なUIコンポーネント
// shadcn/uiのtableコンポーネント
pnpm ui:add table

// tanstack/react-table
pnpm add @tanstack/react-table
BooksTable.tsx
'use client'

import { BaseTable } from '@/components/ui/BaseTable'
import { getBooksWithAuthorAndPublisher } from '@/domain/repository/Books'
import { createColumnHelper } from '@tanstack/react-table'
import { useEffect, useState } from 'react'

type Books = {
  title: string
  authorName: string
  publisherName: string
}

const BooksTable: React.FC = () => {
  const [books, setBooks] = useState<Books[]>([])
  useEffect(() => {
    const getBooks = async () => {
      const data = await getBooksWithAuthorAndPublisher()
      setBooks(
        data.map(book => {
          return {
            title: book.title,
            authorName: book.author.name,
            publisherName: book.publisher.name,
          }
        }),
      )
    }
    getBooks()
  }, [])
  const columnHelper = createColumnHelper<Books>()
  const columns = [
    columnHelper.accessor('title', {
      header: 'タイトル',
      cell: ({ row }) => row.original.title,
    }),
    columnHelper.accessor('authorName', {
      header: '著者',
      cell: ({ row }) => row.original.authorName,
    }),
    columnHelper.accessor('publisherName', {
      header: '出版社',
      cell: ({ row }) => row.original.publisherName,
    }),
  ]

  return <BaseTable className='container p-4' data={books} columns={columns} />
}
BaseTable.tsx
'use client'

import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'

type Props<T, V> = {
  className?: string
  data: T[]
  columns: ColumnDef<T, V>[]
}

export const BaseTable = <T, V>({ className = '', data, columns }: Props<T, V>) => {
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
  })

  return (
    <div className={className}>
      <Table>
        <TableHeader>
          {table.getHeaderGroups().map(headerGroup => (
            <TableRow key={headerGroup.id}>
              {headerGroup.headers.map(header => {
                return (
                  <TableHead key={header.id}>
                    {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
                  </TableHead>
                )
              })}
            </TableRow>
          ))}
        </TableHeader>
        <TableBody>
          {table.getRowModel().rows?.length ? (
            table.getRowModel().rows.map(row => (
              <TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
                {row.getVisibleCells().map(cell => (
                  <TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
                ))}
              </TableRow>
            ))
          ) : (
            <TableRow>
              <TableCell colSpan={columns.length} className='h-24 text-center'>
                登録されたマンガはありません。
              </TableCell>
            </TableRow>
          )}
        </TableBody>
      </Table>
    </div>
  )
}

マンガの登録

マンガのタイトル、著者、出版社を入力して各テーブルへ登録するためのUIを作ります

必要なUIコンポーネント
// shadcn/uiのdialogコンポーネント
pnpm ui:add dialog

// shadcn/uiのbuttonコンポーネント
pnpm ui:add button

// shadcn/uiのinputコンポーネント
pnpm ui:add input

// shadcn/uiのtoastコンポーネント
pnpm ui:add toast

// form作成に使用、shadcn/uiのformはreact-hook-formに依存している
pnpm ui:add form

https://react-hook-form.com/

AddBookDialog.tsx
import { BaseDialog } from '@/components/ui/BaseDialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { FormProvider } from 'react-hook-form'

type Props = {
  trigger: React.ReactNode
}

const AddBookDialog: React.FC<Props> = ({ trigger }) => {
  const { toast } = useToast()
  const router = useRouter()
  const formMethods = useForm()
  const titleId = 'title'
  const authorId = 'author'
  const publisherId = 'publisher'
  const handleCreate = async () => {
    const res = createBooks(
      formMethods.getValues(titleId),
      formMethods.getValues(authorId),
      formMethods.getValues(publisherId),
    )
    res.then(({ title }) => {
      toast({
        title: `${title}を本棚へ登録しました`,
      })
      router.refresh()
    })
  }

  const mainContent = (
    <div className='grid gap-4 py-4'>
      <div className='grid grid-cols-4 items-center gap-4'>
        <Label htmlFor={titleId} className='text-right'>
          タイトル
        </Label>
        <Input {...formMethods.register(titleId)} id={titleId} className='col-span-3' />
      </div>
      <div className='grid grid-cols-4 items-center gap-4'>
        <Label htmlFor={authorId} className='text-right'>
          著者
        </Label>
        <Input {...formMethods.register(authorId)} id={authorId} className='col-span-3' />
      </div>
      <div className='grid grid-cols-4 items-center gap-4'>
        <Label htmlFor={publisherId} className='text-right'>
          出版社
        </Label>
        <Input {...formMethods.register(publisherId)} id={publisherId} className='col-span-3' />
      </div>
    </div>
  )

  const footerContent = (
    <Button type='submit' onClick={handleCreate}>
      登録
    </Button>
  )

  return (
    <FormProvider {...formMethods}>
      <BaseDialog
        trigger={trigger}
        title='マンガの追加'
        mainContent={mainContent}
        footer={{ content: footerContent, withClose: true }}
      />
    </FormProvider>
  )
}

動作確認

これまでに作ってきたものと、ページやスタイルなどを整えて動かしてみたものが下記のgifです

実際にServer Actionsで正しく動作しているか確認するためコンソールへ出力してみたいと思います
先に挙げていたマンガテーブルのselect部分にconsole.logを仕込み、ブラウザまたはコンテナ上のどちらのコンソールへ出力されるか見ていきます

BooksRepository.ts
export async function getBooksWithAuthorAndPublisher() {
  const books = await prisma.i_book.findMany({
    include: {
      author: true,
      publisher: true,
    },
  })

+ console.log('debug getBooksWithAuthorAndPublisher: %o: ', books)

  return books
}

この状態で Google Chrome の developer toos にあるコンソールを確認すると何も出力されていませんが、

コンテナ上で確認すると先程埋め込んだデバッグログが出力されていることから、サーバーサイドで実行されているということができると思います

残った課題

登録後のデータ再取得について

普段はサーバ上のデータはAPIを介して取得するため、例えば React Query などを用いてデータの取得、状態管理などを行っているとします
このとき、取得済みの一覧データにおいて新規登録されたデータを反映するため一覧データの再取得の必要性が生まれた際に、React QueryであればQuery Invalidationを用いて最新化を図っていました
このような動作を期待して Next.js useRouterrouter.refresh() を用いたのですが、期待した動作を得られず解決できませんでした

寂しい見た目…

これはほぼ雑談というか、余談なのですが
本来であれば楽天ブックス系APIを用いて書影の表示や、所持している巻数の記録などマンガ管理アプリとしてもっとたくさんの機能を持たせたかったのですが時間が足らず…

まとめ

Server Actionsや気になっていたライブラリなどを簡単に触ってみた、でした
DB上のデータを参照するためにREST APIやBFFなどが必要ないという体験はとても新鮮なものでした
この記事で触ってみた範囲はほぼQuick Startと同等かそれ未満な内容ばかりでありまだまだ学習の余地があるので、今後も継続して情報を拾って学んでいきたいと思います
一方で、Stableになったといえど諸手を挙げてこの技術を歓迎できるかと問われたら、まだ骨が折れそうだなという印象もあり色々と探っていく日々が続きそうだなとも思いました

以上、Applibot Advent Calendar 2023 16日目の記事でした。

Discussion