1. HOME
  2. ブログ
  3. エンジニアリング
  4. 【Next.js】ServerActionsでデータを変更してみよう

BLOG

ブログ

エンジニアリング

【Next.js】ServerActionsでデータを変更してみよう

本章では、ReactのServer Actionsを使ってデータを作成・変更する方法を学んでいきます。

前半では、Server Actionsの概要について説明します。

後半では、以下のアプリ機能を実装しながら理解を深めていきます。

  • 会員登録
  • 投稿編集
  • 投稿削除
  • コメント投稿
  • プロフィール編集

チュートリアルの全体像

本チュートリアルを通じて学べる内容は以下のようになります。

  1. Next.jsの開発環境をセットアップ
  2. 画面デザインしながら基礎を学ぼう
  3. App Routerを理解して使ってみよう
  4. Next.js+Postgres+PrismaでDB設計
  5. サーバコンポーネントでデータ取得
  6. サーバアクションでデータ変更
  7. Vercel Blobで画像アップロード実装
  8. Suspenseでローディングをリッチに
  9. Zodを使ってバリデーション実装
  10. useActionStateでローディング実装
  11. Auth.js v5で認証機能導入
  12. 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を使ってデータを変更する方法を学びました。

次回は、画像アップロードについて学んでいきます。

  1. この記事へのコメントはありません。

  1. この記事へのトラックバックはありません。

関連記事