【Next.js】Server Actionsを試してみた
この記事は?
Next.js 14 にて Server Actions がStableとなりました
Server ActionsによりAPIを介さずデータを参照できるのか試してみたいと思い簡単なWebアプリケーションを作ってみることにしました
また、この機会に気になっていた shadcn/ui や Prisma、Biome も簡単に触ってみたいと思います
試しながらで書いているためツッコミポイントも多くあるかと思いますが、そのような点を見つけられた方は是非コメントでご指摘ください
環境構築
- Dockerの準備
- Next.jsアプリケーションの作成
- Prismaの準備, スキーマ作成
- Biomeの準備, 設定
- shadcn/uiの準備
パッケージマネージャにはpnpm
を使っているので、これ以外を使っている場合は適宜置き換えていただけると幸いです
IDEにはVSCodeを使用します
Docker準備
Dockerコンテナ上でNext.jsとMySQLが動くよう準備していきたいと思います
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
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
[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
を使うためESLint
をNo
しつつ作成を進めていきます
Prismaの準備、テーブル定義の作成
APIを介さずMySQLのデータを参照するためのORMとしてPrisma
を使っていきたいと思います
下記の公式docを参考に作成を進めていきます
// prismaクライアントの準備
pnpm add -D prisma
// 初期化処理
pnpm dlx prisma init --datasource-provider mysql
schema.prisma
が生成されるので、作成するWebアプリケーションに必要なテーブルを記述していきます
今回はマンガを管理するWebアプリケーションを作りたいので、まずは簡単なマンガテーブル、著者テーブル、出版社テーブルを定義していきます
// 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の参照先情報が記述されるので、自身の環境に合わせて変更します
# 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を参考にインストールを行います
pnpm add --save-dev --save-exact @biomejs/biome
pnpm dlx @biomejs/biome init
初期化処理を終えると下記ファイルが生成されるので、自身の好みで設定を変えていきます
どのようなルールを設定できるかは下記の公式docを参照してください
{
"$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
をいじってみてください
これでlint
とformat
の準備は整ったので、package.json
のlint
などを下記のように書き加えて、pnpm lint
などで実行できるよう整えます
{
"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を参考に進めていきます
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
する必要があるためスクリプトへ追加するためのスクリプトを用意しておきます
{
"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クライアントのインスタンスを生成しています
repository
では、例えばBooksRepository
を下記のように記述しています
ちなみにuse server
は省略できますが、わかりやすさのため敢えて付けています
'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
を指定することにより、当該テーブルとリレーションしているテーブルのレコードをオブジェクトに含めて返してくれるようです
今回のテーブルだと、下記のようなオブジェクトを取得できます
(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
'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} />
}
'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
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
を仕込み、ブラウザまたはコンテナ上のどちらのコンソールへ出力されるか見ていきます
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 useRouter の router.refresh()
を用いたのですが、期待した動作を得られず解決できませんでした
寂しい見た目…
これはほぼ雑談というか、余談なのですが
本来であれば楽天ブックス系APIを用いて書影の表示や、所持している巻数の記録などマンガ管理アプリとしてもっとたくさんの機能を持たせたかったのですが時間が足らず…
まとめ
Server Actions
や気になっていたライブラリなどを簡単に触ってみた、でした
DB上のデータを参照するためにREST APIやBFFなどが必要ないという体験はとても新鮮なものでした
この記事で触ってみた範囲はほぼQuick Startと同等かそれ未満な内容ばかりでありまだまだ学習の余地があるので、今後も継続して情報を拾って学んでいきたいと思います
一方で、Stableになったといえど諸手を挙げてこの技術を歓迎できるかと問われたら、まだ骨が折れそうだなという印象もあり色々と探っていく日々が続きそうだなとも思いました
以上、Applibot Advent Calendar 2023 16日目の記事でした。
Discussion