🐥

Loading.tsxはServer Action中に待機画面を表示しない【NextJS】

2024/06/17に公開

Server Actionで状態遷移するベストプラクティスが知りたい

  1. フォーム入力
  2. データベース更新
  3. 画面更新

というよくある黄金シナリオを考えます。

NextJSはSSR Frameworkなので可能な限りServer Componentを利用することを推奨しています。しかしServer ComponentではHooksが使えません。 これまでのようにAPIを叩く→State更新→画面再レンダリングという黄金のパターンは使えなくなります[1]。そこでhooksに頼らずにデータの更新と再レンダリングを行う新しいNextJSのメンタルモデルに適応する必要があります。

公式ドキュメントでおすすめされる1つの方法はServer Actionを使うことです。今日はこのあたりのベストプラクティスについて備忘録としてまとめます。

Server Action

Server Actionとはサーバーサイドで実行される関数のことです。
例えば次の例ではServer ActionとはcreateInvoice関数のことです。"use server"と宣言された非同期関数なのが特徴です。下の例ではformのactionハンドラとして渡されています。

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

    const rawFormData = {
      customerId: formData.get("customerId"),
      amount: formData.get("amount"),
    };
        console.log(rawFormData);
  }

  return (
    <form action={createInvoice}>
      <div>
        <input type="text" name="customerId" placeholder="Your Customer ID" />
        <input type="text" name="amount" placeholder="Amount" />
        <button type="submit">Submit</button>
      </div>
    </form>
  );
}

ブラウザで開いてSubmitボタンをおしてみてください。console.logによってフォームの入力データが「サーバーの標準出力」に表示されます。console.logに変えてデータベースのIOなどを実施することでこれまで別途用意していたバックエンドサーバーを代替することができます。

サーバーアクションを利用してページ遷移する

サーバーアクションを実行したあと、再度ページ読み込みを行い変更されたデータを取得するのがNextJS流です。一連のワークフローはこのような形。

例で見ていきましょう。
Server Component -> Server Actionの順に実装していきます。

Server Component

Server Componentはサーバーでレンダリングされるコンポーネントです。非同期関数として定義でき、外部情報をフェッチするコンポーネントはServer Componentで実装されるのが通例です。


NextJSにおけるServer/Client Componentの使い分け方

ここではデータベースのセットアップは面倒なので、サーバーサイドにstate.txtというテキストファイルを1枚おいておき、この中身をサーバーのステートとして扱うことにします。

import { promises as fs } from "fs";

async function readState() {
  const file = await fs.readFile(process.cwd() + "/src/app/state.txt");
  return file.toString();
}

export default async function Home() {
  const state = await readState();
  return (
    <div className="flex flex-col gap-1 min-h-screen min-w-screen bg-gray-200 justify-center items-center">
      <h1 className="text-2xl">State: {state}</h1>
      <form>
        <div className="flex flex-col gap-1">
          <input className="focus:border" type="text" name="state" />
          <button
            className={`rounded-lg p-1 text-white bg-neutral-800 focus:outline-none`}
            type="submit"
          >
            Submit
          </button>
        </div>
      </form>
    </div>
  );
}

こんな感じでレンダリングされます。

まだformのactionハンドラが登録されていないのでSubmitボタンをクリックしても何もおきません。サーバーアクションを追加します。

import { promises as fs } from "fs";
import { revalidatePath } from "next/cache";
import { setTimeout } from "timers/promises";

async function readState() {
  const file = await fs.readFile(process.cwd() + "/src/app/state.txt");
  await setTimeout(1000);
  return file.toString();
}
async function changeState(formData: FormData) {
  "use server";
  const filePath = process.cwd() + "/src/app/state.txt";
  const state = formData.get("state") as string | null;
  if (state) {
    await fs.writeFile(filePath, state);
    await setTimeout(1000); // 1秒間サーバー処理が走る想定
    return revalidatePath("/");
  }
}

export default async function Home() {
  const state = await readState();
  return (
    <div className="flex flex-col gap-1 min-h-screen min-w-screen bg-gray-200 justify-center items-center">
      <h1 className="text-2xl">State: {state}</h1>
      <form action={changeState}>
        <div className="flex flex-col gap-1">
          <input className="focus:border" type="text" name="state" />
          <button
            className={`rounded-lg p-1 text-white bg-neutral-800 focus:outline-none`}
            type="submit"
          >
            Submit
          </button>
        </div>
      </form>
    </div>
  );
}

Server Actionはformに登録されたデータを適切に読み取ってローカルファイルを使ったStateを変更します。最後にrevalidatePath("/")を呼び出すことでリダイレクトを行います。

Loading UI

SSRで最も詰まったのがLoading UIです。待機画面にはNextJS専用の機能があってpage.tsxと同じファイル階層にloading.tsxを配置すると、page.tsxをサーバーサイドでレンダリングしている間自動的に待機UIを表示してくれます。

しかしloading.tsxをつかった待機画面が表示されるのはサーバーアクションが実行されたあとです。


ユーザーはServer Actionの実行中はきちんと実行されているかどうかも知らされないまま待ち続けることになります。

Client Component

サーバーからのレスポンスを待たずにユーザーに待機画面を表示するにはClient Componentを使います。

えー結局!?とおもってしまいますが仕方ありません。それでも、パフォーマンス向上のためにClient Copmonentの利用を最小限に抑えます。

どんな待機UIを表示するかはアプリケーションによりますが、ここではボタンの色を変え、待機中は押すことができないようにします。

page.tsx
import SubmitButton from "./SubmitButton";
{...中略}

export default async function Home() {
  const state = await readState();
  return (
    <div className="flex flex-col gap-1 min-h-screen min-w-screen bg-gray-200 justify-center items-center">
      <h1 className="text-2xl">State: {state}</h1>
      <form action={changeState}>
        <div className="flex flex-col gap-1">
          <input className="focus:border" type="text" name="state" />
          <SubmitButton />
        </div>
      </form>
    </div>
  );
}

ボタンのところを別ファイルで定義した<SubmitButton />に差し替えました。

SubmitButton.tsx
"use client";

import { useFormStatus } from "react-dom";

export default function SubmitButton() {
  const { pending } = useFormStatus(); // <form>の内側に配置されたコンポーネントでしか利用できない
  const colorClass = pending ? "bg-neutral-300" : "bg-neutral-800";
  return (
    <button
      className={`rounded-lg p-1 text-white focus:outline-none ${colorClass}`}
      type="submit"
      disabled={pending}
    >
      Submit
    </button>
  );
}

formタグの内側でしか利用できないReact公式のHook useFormStatusを利用してactionを待機しているかどうかを取得します。待機中には色がかわりボタンがdisabledになります。

結論

Loading.tsxを利用した待機画面は再レンダリングの待機中にしか表示されないので待機画面としてはほとんど意味がありません。React Hooksを使った状態管理されたClient Componentを利用して待機画面を実装しましょう。このときClient Componentの範囲を絞らないとパフォーマンスが低下してしまうことに注意しましょう。

脚注
  1. Client Componentを利用すれば使えますがNextJSを使う旨味が少なくなります ↩︎

Discussion