【Next.js】ServerActionsでデータを変更してみよう
本章では、ReactのServer Actionsを使ってデータを作成・変更する方法を学んでいきます。
前半では、Server Actionsの概要について説明します。
後半では、以下のアプリ機能を実装しながら理解を深めていきます。
- 会員登録
- 投稿編集
- 投稿削除
- コメント投稿
- プロフィール編集
目次
チュートリアルの全体像
本チュートリアルを通じて学べる内容は以下のようになります。
- Next.jsの開発環境をセットアップ
- 画面デザインしながら基礎を学ぼう
- App Routerを理解して使ってみよう
- Next.js+Postgres+PrismaでDB設計
- サーバコンポーネントでデータ取得
- サーバアクションでデータ変更
- Vercel Blobで画像アップロード実装
- Suspenseでローディングをリッチに
- Zodを使ってバリデーション実装
- useActionStateでローディング実装
- Auth.js v5で認証機能導入
- Route HandlersでAPIを設計
動画で学びたい方はこちらから!
Server Actionsの基礎知識
Server Actionsとは
Server Actionsは、サーバで実行される非同期関数をコンポーネントから直接呼び出せる機能です。
この機能によって、データ変更用のAPIを定義する手間が省けてシンプルに実装できるのが特徴です。
以下が、Server Actionsのシンプルな定義例です。
app/server-actions/page.tsx
export default function Page() {
async function action(formData: FormData) {
"use server";
console.log(formData.get("message"));
}
return (
<form action={action}>
<input type="text" name="message" />
<button type="submit">送信</button>
</form>
);
}
実際の開発では、Server Actionsの定義を分離することがほとんどです。
ファイルを分割する場合、以下のように記述します。
lib/actions.ts
"use server";
export async function action(formData: FormData) {
console.log(formData.get("message"));
}
app/server-actions.tsx
import { action } from "@/lib/actions";
export default function Page() {
return (
<form action={action}>
<input type="text" name="message" />
<button type="submit">送信</button>
</form>
);
}
ファイルを分割する場合、Server Actionsを定義するファイルの先頭に、”use server” ディレクティブを宣言します。
フォーム入力値の取得
サンプル実装では、テキストに入力された文字列をサーバ側で出力しています。
このように、<form>要素のaction属性に渡されたServer ActionsはWeb標準のFormData型のオブジェクトを参照できます。
FormDataオブジェクトのget()関数を使用するとフォームの入力値を取得することができます。
Server Actionsに引数を渡す
Server Actionsには引数を渡すことができます。
よくあるケースが、データの更新処理です。
"use server";
export async function updatePost(id: string, formData: FormData) {
// 渡されたidをもとに投稿を更新する処理...
}
export default async function Page({ params }: { params: { id: string } }) {
const id = params.id;
const updatePostWithId = updatePost.bind(null, id);
return (
<form action={updatePostWithId}>
<form/>
);
}
上記の例のように、Server Actionsに引数を渡すには、bind関数を利用します。
SNSアプリのデータ変更機能実装
Server Actionsの基本をおさえたので、実際にアプリの機能実装をしながら理解を深めていきましょう。
まずは、会員登録画面の実装を行なっていきます。
会員登録
Server Actionsを定義します。
lib/actions.ts
"use server";
import prisma from "@/lib/prisma";
import bcrypt from "bcrypt";
export async function registerUser(formData: FormData) {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const passwordConfirmation = formData.get("passwordConfirmation") as string;
const user = await prisma.user.findFirst({ where: { email } });
if (user) {
return "このメールアドレスはすでに使われています。";
}
if (password != passwordConfirmation) {
return "パスワードと確認用パスワードが一致しません。";
}
const bcryptedPassword = await bcrypt.hash(password, 10);
try {
await prisma.user.create({
data: {
name,
email,
password: bcryptedPassword,
},
});
} catch (error) {
console.error(error);
return "新規登録に失敗しました。";
}
}
続いてコンポーネントからServer Actionsを呼び出しましょう。
components/pages/register/register-form.tsx
import { registerUser } from "@/lib/actions";
import Link from "next/link";
export default function RegisterForm() {
return (
<form action={registerUser}>
<--...省略...-->
</form>
);
}
以下の内容で登録してみましょう。
データベースに反映されています。
投稿編集
lib/actions.ts
export async function updatePost(id: string, formData: FormData) {
const caption = formData.get("caption") as string;
const email = "user+1@example.com";
const user = await prisma.user.findFirstOrThrow({
where: { email },
});
await prisma.post.update({
where: { id, userId: user.id },
data: {
caption,
},
});
revalidatePath("/dashboard");
redirect("/dashboard");
}
app/(main)/posts/[id]/edit/page.tsx
import BreadCrumbs from "@/components/layouts/bread-crumbs";
import { updatePost } from "@/lib/actions";
import { fetchPost } from "@/lib/apis";
import Image from "next/image";
import Link from "next/link";
export default async function Page({ params }: { params: { id: string } }) {
const id = params.id;
const post = await fetchPost(id);
const updatePostWithId = updatePost.bind(null, id);
return (
<>
<BreadCrumbs title="投稿編集 🐾" />
<div className="mx-auto max-w-5xl">
<div className="mt-8 grid grid-cols-1 gap-1 bg-white md:grid-cols-2">
<Image
className="aspect-[1/1] w-full object-cover"
src={post.image}
width={640}
height={640}
alt="post image"
/>
<div className="p-2">
<h3 className="mb-2 font-semibold">オーナー</h3>
<div className="flex rounded border bg-white p-2">
{post.user.image && (
<Image
className="block aspect-square size-12 rounded-full object-cover"
src={post.user.image}
width={96}
height={96}
alt="user icon"
/>
)}
<div className="pl-4">
<div>
<p className="text-lg font-semibold text-black">
{post.user.name}
</p>
<p className="whitespace-pre-wrap text-sm font-medium">
{post.user.description}
</p>
</div>
</div>
</div>
<form action={updatePostWithId}>
<h3 className="mt-2 font-semibold">キャプション</h3>
<textarea
name="caption"
rows={8}
className="mt-2 w-full rounded border border-gray-300 p-2.5 focus:border-blue-500 focus:ring-blue-500"
>
{post.caption}
</textarea>
<div className="mt-4 flex items-center">
<button
className="inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 active:bg-gray-900"
type="submit"
>
更新
</button>
<Link
href={`/posts/${post.id}`}
className="ml-4 rounded border p-2 text-xs"
>
投稿画面へ
</Link>
</div>
</form>
<!-- ...省略... -->
</div>
</div>
</div>
</>
);
}
投稿削除
lib/actions.ts
export async function deletePost(id: string, _formData: FormData) {
const email = "user+1@example.com";
const user = await prisma.user.findFirstOrThrow({
where: { email },
});
await prisma.post.delete({
where: { id, userId: user.id },
});
revalidatePath("/dashboard");
redirect("/dashboard");
}
app/(main)/posts/[id]/edit/page.tsx
<form action={deletePostWithId}>
<button
type="submit"
>
削除
</button>
</form>
コメント作成
lib/actions.ts
export async function createComment(postId: string, formData: FormData) {
const text = formData.get("text") as string;
const email = "user+1@example.com";
const user = await prisma.user.findFirstOrThrow({
where: { email },
});
const post = await prisma.post.findFirstOrThrow({ where: { id: postId } });
await prisma.comment.create({
data: {
text,
userId: user.id,
postId: post.id,
},
});
revalidatePath(`/posts/${postId}`);
redirect(`/posts/${postId}`);
}
app/(main)/posts/[id]/page.tsx
import BreadCrumbs from "@/components/layouts/bread-crumbs";
import { createComment } from "@/lib/actions";
import { fetchPostwithComments } from "@/lib/apis";
import Image from "next/image";
import Link from "next/link";
export default async function Page({ params }: { params: { id: string } }) {
const id = params.id;
const post = await fetchPostwithComments(id);
const createCommentWithPostId = createComment.bind(null, id);
return (
<>
<!-- ...省略... -->
<form action={createCommentWithPostId}>
<div className="mt-2 flex">
<input
name="text"
className="mr-2 grow rounded-md border border-gray-300 pl-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder="コメントを追加..."
/>
<button
type="submit"
className="inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 active:bg-gray-900"
>
送信
</button>
</div>
</form>
<-- ...省略... -->
</>
);
}
プロフィール編集
lib/actions.ts
export async function updateMe(formData: FormData) {
const email = "user+1@example.com";
const user = await prisma.user.findFirstOrThrow({
where: { email },
});
const data = {
name: formData.get("name") as string,
description: formData.get("description") as string,
image: user.image,
};
await prisma.user.update({
where: { email },
data,
});
revalidatePath("/dashboard");
redirect("/dashboard");
}
components/pages/profile/profile-form.tsx
import { updateMe } from "@/lib/actions";
<form action={updateMe} className="relative mt-6 space-y-6">
まとめ
以上、Next.jsのServer Actionsを使ってデータを変更する方法を学びました。
次回は、画像アップロードについて学んでいきます。
この記事へのコメントはありません。