Server Actionsを、フォームの流れから理解する

Server ComponentsとServer Actionsの役割、フォーム、保留状態、楽観的更新、キャッシュ更新までを整理します。

Published
2023年7月29日
Read in English

<form action={createArticle}> と書くだけで、API Routeを1つも作らずにフォームを送信できます。呼び出しの見た目は関数呼び出しですが、実体はネットワークを越えるサーバー処理です。

Next.jsとGraphQLを組み合わせたPoCでServer Actionsを試したときの所感を、登壇で話しました。初稿から大きく変わった部分は、現在のAPIに合わせて書き直しています。

Server Componentsを振り返る

App Routerでは、ページとレイアウトは既定でServer Componentsです。Server Componentはサーバーで実行され、データ取得やレンダリングに必要な処理をクライアントのJavaScriptへ含めずに済みます。

export default async function Page() {
  const articles = await fetchArticles();

  return (
    <main>
      <ArticleList articles={articles} />
      <FavoriteButton />
    </main>
  );
}

サーバーでレンダリングされた結果は、RSC PayloadとHTMLとしてクライアントへ送られます。状態、イベントハンドラー、useEffectwindowlocalStorage などのブラウザAPIが必要な部分だけ、"use client" を付けたClient Componentにします。

"use client";

export function FavoriteButton() {
  return <button onClick={() => {}}>Favorite</button>;
}

Server Componentsは、境界を選ぶための仕組みです。データと静的な構造はサーバーへ置き、操作が必要な場所だけをClient Componentにします。すべてをサーバーへ移すことが目的ではありません。

Server Actionsとは

Next.jsのServer Actionsは、クライアントから呼び出せる非同期のサーバー関数です。フォーム送信やデータ更新を、別のAPI Routeを手作業で用意せずに実装できます。

export default function Page() {
  async function createArticle(formData: FormData) {
    "use server";

    const title = formData.get("title");
    await saveArticle({ title });
  }

  return (
    <form action={createArticle}>
      <input name="title" />
      <button type="submit">Create</button>
    </form>
  );
}

formaction へServer Actionを渡すと、入力値は FormData として関数へ届きます。フォームはJavaScriptの読み込み前でも送信できるため、プログレッシブエンハンスメントとも相性がよい仕組みです。

一方、Server Actionは公開されたサーバー処理です。引数を信頼せず、認証と認可、入力検証、エラー処理を関数内で行う必要があります。

クライアントからActionを呼び出す

ファイルの先頭に "use server" を置くと、そのファイルからエクスポートする関数をClient Componentから呼び出せます。

// actions.ts
"use server";

export async function updateFavorite(id: string, favorite: boolean) {
  await saveFavorite({ id, favorite });
}
// favorite-button.tsx
"use client";

import { startTransition } from "react";
import { updateFavorite } from "./actions";

export function FavoriteButton({ id, favorite }) {
  return (
    <button
      onClick={() => {
        startTransition(async () => {
          await updateFavorite(id, !favorite);
        });
      }}>
      Favorite
    </button>
  );
}

フォーム以外から呼ぶ場合も、通常のイベントハンドラーに近い形で書けます。ただし、ネットワークを越える非同期処理であることは変わりません。保留状態、失敗時の表示、連打、古いレスポンスとの競合を考える必要があります。

保留状態を表示する

フォームの結果や保留状態を扱うなら、Reactの useActionState を利用できます。返り値の isPending を使い、処理中の表示や多重送信の防止を実装します。

"use client";

import { useActionState } from "react";
import { createArticle } from "./actions";

const initialState = { message: "" };

export function ArticleForm() {
  const [state, formAction, isPending] = useActionState(
    createArticle,
    initialState,
  );

  return (
    <form action={formAction}>
      <input name="title" />
      <button disabled={isPending}>
        {isPending ? "Creating…" : "Create"}
      </button>
      <p aria-live="polite">{state.message}</p>
    </form>
  );
}

Actionから想定内の検証エラーを返し、useActionState で表示すると、エラー画面へ遷移させずフォーム内で回復できます。予期しない例外はError Boundaryで扱います。

楽観的に更新する

お気に入りボタンのように、成功する可能性が高く、元へ戻せる操作には useOptimistic が使えます。サーバーの応答を待たずに予想結果を表示し、失敗した場合は元の値へ戻します。

"use client";

import { startTransition, useOptimistic } from "react";
import { updateFavorite } from "./actions";

export function FavoriteButton({ id, favorite }) {
  const [optimisticFavorite, setOptimisticFavorite] = useOptimistic(favorite);

  function handleClick() {
    startTransition(async () => {
      setOptimisticFavorite((current) => !current);
      await updateFavorite(id, !favorite);
    });
  }

  return (
    <button onClick={handleClick} aria-pressed={optimisticFavorite}>
      {optimisticFavorite ? "Favorited" : "Favorite"}
    </button>
  );
}

like button sample

楽観的更新は、失敗しても安全に戻せる操作へ限定します。決済や在庫確保のように、成功を確認する前に確定したように見せるべきでない処理には向きません。

更新後にキャッシュを無効化する

データを更新したら、画面が参照しているキャッシュも更新する必要があります。現在のNext.jsでは、キャッシュ戦略は利用するレンダリング方式やCache Componentsの設定によって異なります。古い記事にあった「fetch のGETは常に既定でキャッシュされる」という前提は、現在のNext.jsには当てはまりません。

キャッシュしたデータにはタグを付け、Server Actionの更新後に updateTagrevalidateTag を呼び出せます。特定のページを更新するなら revalidatePath も利用できます。

"use server";

import { updateTag } from "next/cache";

export async function updateFavorite(id: string, favorite: boolean) {
  await saveFavorite({ id, favorite });
  updateTag("favorites");
}

updateTag はServer Action内で「自分の更新を直後の表示へ反映する」用途に向いています。revalidateTag は、ほかの閲覧者も含め、次回のアクセスで古いデータを再検証させる用途に使います。キャッシュを導入する前に、どのデータを、どの期間、誰に対して再利用してよいかを決めることが重要です。

さいごに

Server ComponentsとServer Actionsを組み合わせると、データ取得と更新をサーバーの近くへ置いたまま、クライアントの操作性を必要な場所へだけ足せます。フォーム、保留状態、楽観的更新、キャッシュ無効化までが一つのモデルでつながる点は魅力です。

ただし、冒頭のとおり、書き味が関数呼び出しでも認証、入力検証、エラー処理、競合、キャッシュ設計はなくなりません。API Routeが見えなくなったぶん、ネットワーク境界を意識して設計する必要があります。

参考