blue-bird

Next.js の Server Actions について

Next.js の Server Actions はサーバーサイドのデータのミューテーション、クライアント JavaScript の削減、プログレッシブエンハンスメントなフォームを実現します。

Server Actions は 2023/05/06 現在 Alpha 版の機能です。この記事で紹介している内容は今後変更される可能性があります。

Next.js の Server Actions はクライアントサイドのフォームの送信やボタンクリックなどのイベントからサーバーサイドで実行される関数を呼び出せます。クライアント JavaScript の削減、プログレッシブエンハンスメントなフォームを実現します。

Server Actions をはじめる

Server Actions を使うには、まず next.config.jsexperimental: { serverActions: true } を追加します。この設定により、Next.js が React の実験的な機能である React Canary を使用するようになります。また、従来の pages ディレクトリではなく App Router でなければ Server Actions を使用できません。

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

設定が完了したら、一番シンプルな Server Actions を作成してみましょう。app/page.tsx ファイルを作成し、以下のように記述します。

app/page.tsx
import { sql } from "@vercel/postgres";
 
async function addTweet(formData: FormData) {
  "use server";
 
  const tweet = formData.get("tweet");
 
  await sql`INSERT INTO tweets (tweet, likes) VALUES (${
    tweet?.toString() || ""
  }, ${0})`;
}
export default async function Home() {
  return (
    <main>
      <form action={addTweet}>
        <textarea name="tweet"></textarea>
        <button>Tweet</button>
      </form>
    </main>
  );
}

Home コンポーネントのフォームは JavaScript で状態管理を行わない非制御コンポーネントとなっています。Remix や SvelteKit のフォームのようにネイティブの HTML の要素のみで完結しています。実際にブラウザの設定で JavaScript を無効にしてもフォームは動作することを確認できます。

JavaScript が使えない環境においては、HTML の機能でフォームを送信してページ全体が再読み込みされます。JavaScript が有効となっている場合、JavaScript によりフォームの送信が実施されるので、ページ全体が再読み込みされないなど、よりよいユーザー体験を提供できます。このようのこのように古いブラウザや機能の限られた端末のユーザーをサポートしつつ、モダンなブラウザのユーザーにはリッチなユーザー体験を提供することはプログレッシブエンハンスメントと呼ばれています。

通常の HTML のフォームと唯一異なる点は、<form> タグの action 属性に addTweet という JavaScript の関数を指定している点です。addTweet 関数はフォームがサブミットした際にサーバーサイドで実行されます。実際にこの例においては、sql タグを使ってサーバーサイドのみでアクセスできるデータベースに insert しています。このように、サーバーサイドでのみ実行される関数を Server Actions 関数と呼ぶことにします。

"use server" ディレクティブ

Server Actions 関数は関数の内部のトップレベルで "use server ディレクティブを宣言する必要があります。

app/page.tsx
async function addTweet(formData: FormData) {
  "use server";
 
  // ...
}

"use server" ディレクティブを宣言していない関数を <form>action 属性に指定した場合、クライアントサイドで実行される関数として扱わてしまうためエラーが発生します。

Unhandled Runtime Error Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with use server.

"use server" ディレクティブはファイルのトップレベルに宣言することも可能です。この場合、ファイル内のすべての関数がサーバーサイドで実行される関数として扱われるので、ファイル内にクライアントコンポーネントを配置できません。

app/actions.ts
"use server";
 
import { sql } from "@vercel/postgres";
 
async function addTweet(formData: FormData) {
  // ...
}
app/page.tsx
import { addTweet } from "./actions";
 
export default function Home() {
  reutrn (
    <main>
      <form action={addTweet}>
      </form>
    </main>
  );
}

引数と戻り値

Server Actions 関数の引数と戻り値は React Server Component のプロトコルに従いシリアライズされます。そのため、例えば Error オブジェクトを throw などシリアライズできないオブジェクトを返すことができません。

Warning: Only plain objects can be passed to Client Components from Server Components. Error objects are not supported.
  {: Error}

React Sever Component のシリアライズについては以下の記事が参考になります。

<form>action 属性に関数を渡した場合、引数として FormData オブジェクトを受け取るので通常のフォームを処理するのと同様に実装できます。

app/page.tsx
async function addTweet(formData: FormData) {
  "use server";
 
  const tweet = formData.get("tweet");
 
  await sql`INSERT INTO tweets (tweet, likes) VALUES (${
    tweet?.toString() || ""
  }, ${0})`;
}

データ更新後の revalidate

addTweet 関数によるデータベースの更新を行った後、正しく保存されているか確認するためにツイートの一覧を表示するようにコンポーネントを変更してみましょう。以下のように、sql 関数を使ってデータベースからツイートの一覧を取得して表示します。

  export default async function Home() {
+   const { rows } = await sql`SELECT * FROM tweets ORDER BY created_at DESC`;
 
    return (
      <main>
        <form action={addTweet}>
          {/* ... */}
        </form>
 
+       <ul>
+         {rows.map((tweet) => (
+           <li key={tweet.id}>
+             <p>{tweet.tweet}</p>
+             <button>Like {tweet.likes}</button>
+           </li>
+         ))}
+       </ul>
      </main>
    );
  }

投稿したツイートの一覧を表示できるようになりましたが、1 つ問題があります。tweet ボタンでフォームを送信した後もツイートの一覧が表示されず、再度ページを読み込み必要があるのです。

Server Actions 関数内でデータの更新を実施した場合には redirect を呼び出すか、revalidatePath または revalidateTag を呼び出してデータの再検証を行いキャッシュを更新する必要があります。

Server Actions 関数内で revalidatePath を呼び出すように修正してみましょう。revalidatePath は引数として渡したパスに紐づくデータを再検証します。sql でデータを更新した後に revalidatePath を呼び出すように修正します。

  import { sql } from "@vercel/postgres";
+ import { revalidatePath } from "next/cache";
 
  async function addTweet(formData: FormData) {
    "use server";
 
    const tweet = formData.get("tweet");
 
    await sql`INSERT INTO tweets (tweet, likes) VALUES (${
      tweet?.toString() || ""
    }, ${0})`;
 
+  revalidatePath("/");
  }

再度ツイートの投稿を試してみると、画面の更新を行わずとも新しいツイートが表示されるようになりました。

useTransition を使用したカスタム Server Actions

Server Actions フックはフォーム内の action や submit button の formAction 以外の場所でも使用できます。例として、ボタンをクリックしたときにいいね数を更新するような処理を実装してましょう。これは useTransition フックが提供する startTrantion 関数を使用して実装できます。

useTransition 関数はクライアントコンポーネントでのみ使用できるフックですので、いいねボタンの部分をクライアントコンポーネントして分割する必要があります。app/LikeButton.tsx ファイルを作成し、先頭で "use client" ディレクティブを宣言します。

app/LikeButton.tsx
"use client";
 
import { useTransition } from "react";
import { likeTweet, Tweet } from "./actions";
 
type Props = {
  tweet: Tweet;
};
 
export default function LikeButton({ tweet }: Props) {
  const [isPending, startTransition] = useTransition();
  return (
    <button onClick={() => startTransition(() => likeTweet(tweet.id))}>
      {isPending ? "loading..." : "Like"} {tweet.likes}
    </button>
  );
}

"use client" ディレクティブを宣言しているファイル内には Server Actions 関数を配置することはできません。そのため、ツイートのいいね数の更新を行う likeTweet 関数は app/actions.ts に配置しています。app/actions ファイルは先頭に "use server" ディレクティブを宣言しているため、likeTweet 関数は Server Actions として実行されます。

app/actions.ts
"use server";
 
import { sql } from "@vercel/postgres";
import { revalidatePath } from "next/cache";
 
export type Tweet = {
  id: string;
  tweet: string;
  likes: number;
  created_at: Date;
};
 
export async function likeTweet(id: string) {
  await sql`UPDATE tweets SET likes = likes + 1 WHERE id = ${id}`;
 
  revalidatePath("/");
}

app/page.tsx ファイルを修正して LikeButton コンポーネントを使用するようにします。

app/page.tsx
  export default async function Home() {
    const { rows } = await sql`SELECT * FROM tweets ORDER BY created_at DESC`;
 
    return (
      <main>
        <form action={addTweet}>
          {/* ... */}
        </form>
 
        <ul>
          {rows.map((tweet) => (
            <li key={tweet.id}>
              <p>{tweet.tweet}</p>
-             <button>Like {tweet.likes}</button>
+             <LikeButton tweet={tweet} />
            </li>
          ))}
        </ul>
      </main>
    );
  }

これで、いいねボタンをクリックしたときにいいね数が更新されるようになりました。

<form> 以外の場所で Server Actions を使用する場合は、プログレッシブエンハンスメントが無効になることに注意してください。つまり、JavaScript が無効となっている環境ではボタンをクリックしても何も起こらないということです。できる限り JavaScript が読み込まれない環境をサポートしたい場合には、onClickServer Actions 関数を呼び出すのではなく、ボタンを <form> で囲ってフォームとして扱うのが良いでしょう。

useOptimistic を使用して楽観的更新を実現する

React の実験的な機能である useOptimistic フックを使うことで、楽観的な更新を提供できます。楽観的な更新とは、サーバーにリクエストを送信する前にクライアント側でデータを更新することです。これにより、ユーザーはサーバーからのレスポンスを待つことなく、すぐにデータの更新を確認できます。もしサーバサイドのエラーなどで処理が失敗した場合には、いいね数をフォールバックするなど適切なフィードバックをユーザーに提供する必要があります。

useOptimistic フックは useTransition フックと同様にクライアントコンポーネントでのみ使用できます。いいねボタンをクリックしたときに楽観的な更新を実現するように修正してみましょう。

app/LikeButton.tsx
"use client";
 
import { experimental_useOptimistic as useOptimistic } from "react";
import { likeTweet, Tweet } from "./actions";
 
type Props = {
  tweet: Tweet;
};
 
export default function LikeButton({ tweet }: Props) {
  const [optimisticLikes, addOptimisticLikes] = useOptimistic(
    tweet.likes,
    (state, newLikes: number) => state + newLikes
  );
  return (
    <button
      onClick={async () => {
        addOptimisticLikes(1);
        await likeTweet(tweet.id);
      }}
    >
      {optimisticLikes} Likes
    </button>
  );
}

useOptimistic フックの第 1 引数には現在の状態を、第 2 引数には状態を更新する関数を渡します。useOptimistic フックの返り値の配列の 2 つ目の関数を呼び出すことで状態を更新できます。

ここではボタンがクリックされたときに addOptimisticLikes を呼び出してクライアント側の状態を更新した後に likeTweet 関数でサーバーにリクエストを送信しています。これにより、ボタンをクリックしたとき即座にいいね数が更新されるようになりました。

サーバー側の処理が失敗した場合には、revalidatePath 関数が呼ばれてデータの再検証が行われるため、クライアント側の状態がサーバー側の状態と同期されます。以下のように意図的にエラーを発生させて試すことができます。

app/actions.ts
export async function likeTweet(id: string) {
  try {
    throw new Error("test");
    await sql`UPDATE tweets SET likes = likes + 1 WHERE id = ${id}`;
  } catch (error) {
    console.error(error);
  } finally {
    revalidatePath("/");
  }
}

useFormStatus をしてフォームの状態を表示する

useFormStatus フックをフォームアクションと合わせて使用すると、フォームが送信中であることを示す pending 状態が得られます。pending 状態の場合にはフォームの送信ボタンを無効にして、送信中であることをユーザーに伝えましょう。フォーム部分を app/TweetForm.tsx に切り出して "use client" を宣言します。ついでに、addTweet 関数も app/actions.tsx に移動しておきましょう。

2023/05/06 現在では、以下のコードで動作しませんでした。

app/TweetForm.tsx
"use client";
 
import { addTweet } from "./actions";
import { experimental_useFormStatus as useFormStatus } from "react-dom";
 
export default function TweetForm() {
  const { pending } = useFormStatus();
 
  return (
    <>
      <form action={addTweet}>
        <textarea name="tweet"></textarea>
        <button disabled={pending}>Tweet</button>
        {pending && <p>Submitting...</p>}
      </form>
    </>
  );
}

バリデーション

フォームのバリデーションはクライアントサイドで行うものと、サーバサイドで行うものの 2 つがあるでしょう。例えば、ツイートの文字列制限(1 文字以上 140 文字以下など)はクライアントサイドでも検証できるため、サーバーにデータを送信する前に即座にフィードバックを返すことができます。一方で、1 日のツイート数に制限がある場合は、サーバサイドでなければ検証できません。

まずはクライアントサイドで実施するバリデーションを見てみましょう。以下のように、Server Actions 関数を呼び出す前にクライアント側でバリデーションを行います。addTweet の前に呼び出されるインラインの関数は "use server" ディレクティブを宣言しないので、クライアントサイドで実行されます。

app/TweetForm.tsx
  export default function TweetForm() {
+   const [error, setError] = useState<string | null>(null);
    const { pending } = useFormStatus();
 
    return (
      <>
        <form
-        action={addTweet}       
+        action={async (formData) => {
+          setError(null);
+          const tweet = formData.get("tweet");
+          if (typeof tweet !== "string" || tweet.length === 0) {
+            setError("Tweet cannot be empty");
+            return;
+          }
+          if (tweet.length > 140) {
+            setError("Tweet cannot be longer than 140 characters");
+            return;
+          }
+          await addTweet(tweet);
          }}
        >
          <textarea name="tweet"></textarea>
          <button disabled={pending}>Tweet</button>
          {pending && <p>Submitting...</p>}
          {error && <p style={{ color: "red" }}>{error}</p>}
        </form>

addTweet 関数はもはや action 属性に直接渡さないようになったので、引数に formData ではなく文字列を受け取るように変更しましょう。

app/actions.ts
- export async function addTweet(formData: FormData) {
+ export async function addTweet(tweet: string) {
  "use server";
 
- const tweet = formData.get("tweet") as string;
 
  await sql`INSERT INTO tweets (tweet, likes) VALUES (${tweet}, ${0})`;
 
  revalidatePath("/");
}

投稿された後にフォームの入力内容をリセットしたい場合には useRef でフォームの参照を取得して reset メソッドを呼び出すことができます。

app/TweetForm.tsx
+ import { useRef } from "react";
 
  export default function TweetForm() {
    const [error, setError] = useState<string | null>(null);
+   const formRef = useRef<HTMLFormElement>(null);
 
    return (
      <>
        <form
          action={async (formData) => {
            setError(null);
            const tweet = formData.get("tweet");
            if (typeof tweet !== "string" || tweet.length === 0) {
              setError("Tweet cannot be empty");
              return;
            }
            if (tweet.length > 140) {
              setError("Tweet cannot be longer than 140 characters");
              return;
            }
            await addTweet(tweet);
+           formRef.current?.reset();
          }}  
+         ref={formRef}
        >

続いてサーバサイドのバリデーションを考えてみましょう。Server Actions 関数内で Error を throw してもクライアント側でキャッチできないので、関数の返り値としてエラー情報を返すようにします。ここでは、関数の引数と返り値は React Server Component のプロトコルとしてシリアライズ可能な形式でなければいけないことに注意してください。

app/TweetForm.tsx
  export default function TweetForm() {
    const [error, setError] = useState<string | null>(null);
    const formRef = useRef<HTMLFormElement>(null);
 
    return (
      <>
        <form
          action={async (formData) => {
            setError(null);
            const tweet = formData.get("tweet");
            if (typeof tweet !== "string" || tweet.length === 0) {
              setError("Tweet cannot be empty");
              return;
            }
            if (tweet.length > 140) {
              setError("Tweet cannot be longer than 140 characters");
              return;
            }
-           await addTweet(tweet);
+           const result = await addTweet(tweet);
+           if (result.success === false) {
+             setError(result.error); 
+             return;
+           }
+           formRef.current?.reset();
          }}
          ref={formRef}
        >
app/actions.ts
const maxDailyTweets = 10;
let tweetCount = 0;
 
type Result =
  | {
      success: true;
    }
  | {
      success: false;
      error: string;
    };
 
export async function addTweet(tweet: string): Promise<Result> {
  "use server";
  if (tweetCount > maxDailyTweets) {
    return {
      success: false,
      error: "Exceeds the number of tweets possible per day",
    };
  }
 
  try {
    await sql`INSERT INTO tweets (tweet, likes) VALUES (${tweet}, ${0})`;
  } catch (error) {
    console.error(error);
    return { success: false, error: "Failed to add tweet" };
  }
 
  tweetCount++;
 
  revalidatePath("/");
  return { success: true };
}

まとめ

  • Server Actions により、フォームの送信やボタンクリックなどクライアントサイドのイベントから、データベースの更新のようにサーバサイドの処理を呼び出すことができる
  • データの更新後は revalidatePath または revalidateTag を呼び出してキャッシュを更新する
  • useOptimistic で楽観的な更新を、useFormStatus フックを使うとフォームの送信状態を取得できる
  • クライアントサイドのバリデーションの Server Actions 関数を呼び出す前に、サーバサイドのバリデーションの Server Actions 関数の返り値によって実施する

今回使用したコードは以下のレポジトリから参照できます。


Contributors

> GitHub で修正を提案する
この記事をシェアする
はてなブックマークに追加

関連記事