Next.jsなどで使えるReactのServer Actions機能について。そういやこの機能に関するメモ、自分で作成してなかったよなと思ったので書いておきます。

Server Actionsとは

自分自身は「Reactでバックエンドコードをフレームワークの状態管理下に置く機能」と解釈しています。Next.jsの機能っぽく見えますが、厳密にはReactの機能です。
が、組み込みサポートやrevalidatePathのようなWebフレームワーク提供の関数と組み合わせ使ったりする関係上、実際にNext.jsなど一部フレームワーク専用の機能みたいな感じになっているのが実情です。これは、Reactの開発グループでVercel社の権力がかなり増してるから、みたいな事情があったりもします。近年、ReactのUIフレームワークはほぼNext.jsで、あるいはRemixか、みたいな独占的な状況になりつつあるような印象がありますが、そういう事情があります。設計思想についてはPHPの影響が強いらしいです。

基本について

典型的なアクション関数とは、以下のようなものです。

"use server";

export async function incrementNumber(n: number) {
  return n + 1;
}

これをuseTransitionと組み合わせ、startTransitionのコールバック内で呼びます。するとデータをシリアライズした特殊なPOSTリクエストが走り、サーバー側で実行しているのに、あたかもクライアントで実行しているのような記述が実現できます。

"use client";

import { incrementNumber, decrementNumber } from "@/actions/count";
import { useState, useTransition } from "react";

export default function Home() {
  const [count, setCount] = useState(0);
  const [isPending, startTransition] = useTransition();

  const increment = () => {
    startTransition(async () => {
      const c = await incrementNumber(count);
      setCount(c);
    });
  };

  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      <div className="flex flex-col items-center">
        <p>{count}</p>
        <div className="flex flex-row gap-2">
          <button onClick={increment} disabled={isPending}>up</button>
        </div>
      </div>
    </main>
  );
}

Server ActionsのActionsって言う所以の発想はformタグなどのactionまたはformAction属性でアクション関数を渡すことに起因します。非常に面白いこととして、useFormStateを用いることで状態のディスパッチ関数の内部実装をバックエンド側のアクションに仕込むことができます。

"use client";

import { incrementNumber } from "@/actions/count";
import { useFormState } from "react-dom";

export default function Home() {
  const [count, dispatch, isPending] = useFormState<number>(incrementNumber, 0);

  return (
    <main className="flex min-h-screen flex-col items-center gap-4 p-24">
      <div className="flex flex-col gap-2">
        <p>{count}</p>
        <form action={dispatch}>
          <button disabled={isPending}>Increment</button>
        </form>
      </div>
    </main>
  );
}

Todoリストで追加するサンプル

jsonファイルをDBと見立てて、こんな感じで書けるんじゃないかと思ったテストです。Actionは以下のような感じ。<form>で仕込む場合、Actionの第1引数はFormDataでリクエストデータが取れます。注目するところはrevalidatePathで、これを使うとクライアントに特定URLの影響キャッシュを破棄してクライアントに再描画するよう指示できます。要は一部だけリダイレクト、みたいなことができるイメージ。他にもrevalidateTagsなんて関数があり、これはNext専用のfetch関数のnextオプションでtagsを設定していた場合、それに関するデータを再検証する、というものになっています。

"use server";

import { revalidatePath } from "next/cache";
import { writeFileSync, readFileSync, existsSync } from "node:fs";

export type Todo = {
  id: string;
  text: string;
  completed: boolean;
};

const DB_PATH = "todos.json";

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export async function findAllTodos(): Promise<Todo[]> {
  if (!existsSync(DB_PATH)) {
    writeFileSync(DB_PATH, JSON.stringify({ todos: [] }), "utf-8");
  }

  await sleep(500);

  // read from db
  const data = JSON.parse(readFileSync(DB_PATH, "utf-8"));

  return data && data.todos ? data.todos : [];
}

export async function addTodo(data: FormData) {
  const text = data.get("text") as string;

  const todo: Todo = {
    id: Math.random().toString(36).substring(7),
    text: text,
    completed: false,
  };

  await sleep(500);

  // save to db
  const prevData = await findAllTodos();
  const newData = { todos: [...prevData, todo] };
  writeFileSync(DB_PATH, JSON.stringify(newData), "utf-8");

  // 指定パスのクライアントキャッシュを削除し、再検証させる -> ページを再描画
  revalidatePath("/");
}

で、サーバコンポーネント。

import { type Todo, addTodo, findAllTodos } from "@/actions/todo";
import { AddButton } from "./button";

const TodoList = ({ todos }: { todos: Todo[] }) => {
  return (
    <ul className="flex flex-col gap-4">
      {todos.map((todo) => (
        <li key={todo.id} className="flex items-center gap-4">
          <input type="checkbox" checked={todo.completed} readOnly />
          <span>{todo.text}</span>
        </li>
      ))}
    </ul>
  );
};

export default async function Home() {
  const todos = await findAllTodos();

  return (
    <main className="flex min-h-screen flex-col items-center gap-4 p-24">
      <h1 className="text-4xl font-bold">Todos</h1>
      <form action={addTodo} className="flex flex-row gap-6">
        <input
          className="text-black"
          type="text"
          name="text"
          placeholder="Add a todo"
        />
        <AddButton type="submit">Add</AddButton>
      </form>
      <div className="flex flex-row">
        <TodoList todos={todos} />
      </div>
    </main>
  );
}

内部にクライアントコンポーネントをかませた場合、useFormStatusを使うと送信中などの状態を取得できるようになっています。

"use client";

import { type ComponentPropsWithoutRef } from "react";
import { useFormStatus } from "react-dom";

interface AddButtonProps extends ComponentPropsWithoutRef<"button"> {}

export const AddButton = (props: AddButtonProps) => {
  const { pending } = useFormStatus();

  return (
    <button disabled={pending} {...props}>
      {pending ? "Adding..." : "Add"}
    </button>
  );
};

で、どれ使うんだ

Server Actionsの他にもフロント・バックエンド間の状態取得の非同期的管理の方法が色々あって、Route HandlerTanStack Queryなど、とにかく色々な手段があるので区分けしないとなあと思いますよね。自分自身このあたりは混乱している部分もあります。
Server Actionsの場合、状態管理のプロセスをバックエンドに隠蔽できることと、フレームワーク依存がかなり強いことが特徴と言えます。つまり、クライアントの負担を減らせるのですが、一方で別の箇所で諸々が密結合する諸刃の剣、みたいなところもあるので注意が必要でしょう。まあ、主にV社のビジネス的な理由も多分あり、そう狙っています。しかし、React本体がこちらに舵を切っているので、大きな潮流はServer Actionsとかに向かうんだろうなあと思います。