拡張されたFetch API
App Router固有の話題として特徴的なのは、サーバー側でもWeb標準に準拠したFetch APIが使えることです。RequestやResponseといった、ブラウザでもお馴染みのインターフェースが使えるため、ブラウザ向けにReactコンポーネントを書くときとほとんど同じ感覚で、コンテキストスイッチを最小限にして通信処理を記述できます。
App RouterのFetch APIは、ただWeb標準を再現しているだけではなく、特にキャッシュ周りの挙動が強化されています。キャッシュをうまく扱うことで、ブラウザからの見かけ上のデータ取得を高速化するチャンスがあるためです。
まず、Fetch APIのブラウザでの挙動と違う点として、fetch() 関数のオプションである cache の値が、デフォルトで 'force-cache' になっています。明示的に書いた場合はリスト2のようなイメージです。
const res = await fetch('https://example.com/api/hoge', {
cache: 'force-cache',
});
// 以下のコードと同じ
// const res = await fetch('https://example.com/api/hoge');
リスト2の(つまりデフォルトの)設定で fetch() 関数を実行した場合、まずは必ずキャッシュを検索し、ヒットするものがあれば新しいか古いかに関係なく、そのキャッシュを返します。キャッシュがヒットしなかった場合は、通常の通信を行い、ダウンロードしたリソースでキャッシュを更新します。
この挙動はデフォルトの挙動としてはやや極端です。一度取得したら、基本的にサーバーを再起動するまでキャッシュが更新されないので、データソースの更新がなかなかブラウザに反映されないことになり、デバッグ中にリロードボタンを連打する罠にハマりがちです。リロードするたびに新しい情報を取得してもよい画面を作っている場合には、リスト3のように cache オプションに 'no-store' を指定します。
const res = await fetch('https://example.com/api/hoge', {
cache: 'no-store',
});
'no-store' を指定することにより、キャッシュを確認せずに常に通信を行うようになります。慣れるまでは、手癖でこの設定を使ってもいいかもしれません。
キャッシュを破棄する方法
cache オプションはWeb標準にも存在するものでしたが、App Router独自の機能として、キャッシュを破棄するためのAPIが用意されています。'force-cache' の挙動と合わせて使うことを前提として読んでください。
App Routerでは、キャッシュを破棄して新しいデータを読み込めるようにする方法として、大きく分けて2つのアプローチがあります。
- 時間経過による破棄
- オンデマンドな破棄
それぞれ解説していきましょう。まず、時間経過による破棄は、指定した時間の経過後に発生したリクエストでキャッシュを破棄する方法です。fetch() のオプションの独自拡張である next.revalidate を利用します(リスト4)。
const res = await fetch('https://example.com/api/hoge', {
next: {
revalidate: 3600, // (1)
},
});
(1) で3600秒(1時間)後を指定したので、キャッシュを作成してから1時間以上が経過すると、次のリクエストでキャッシュが破棄・更新されます。定期的に内容が変わるタイプの、ランキングなどのデータについては、この方法でキャッシュを破棄するとよいでしょう。
次に、オンデマンドな破棄は、任意のタイミングでキャッシュを破棄する方法です。オンデマンドな破棄を行うための関数は2種類用意されています。
ひとつは revalidatePath() です。これは引数にページのパスを文字列で取ります。つまり、app/todos/page.js というファイルを作成した場合、revalidatePath('/todos') というように、ページのパスとなる文字列を渡します。この関数を呼び出すと、指定したパスのページ構築に使われている、すべての fetch() でキャッシュが破棄・更新されます。
もうひとつは revalidateTag() です。これは、fetch() のオプション next.tags と組み合わせて使います。まず、キャッシュの破棄を制御したい fetch() に next.tags オプションを追加します。このオプションは、文字列の配列を受け取ります(リスト5)。
import { revalidateTag } from "next/cache";
const res = await fetch('https://example.com/api/hoge', {
next: {
tags: ['hoge', 'fuga'], // (1)
},
});
// 任意のタイミングでキャッシュを破棄する
revalidateTag('hoge'); // (2)
(1) では、hoge と fuga というタグを指定しています。この fetch() で取得したデータは、revalidateTag() によってタグを指定してキャッシュを破棄することができます。revalidateTag() は引数に文字列を取ります。fetch() で指定したタグのうち、いずれかのタグが一致するキャッシュが破棄されます。つまり、(2)のように revalidateTag('hoge') とすると、fetch() で指定した hoge というタグが一致するキャッシュが破棄されます。
このように、App RouterはWeb標準のFetch APIを活用しつつ、キャッシュの破棄・更新を行うための独自のオプションを提供しています。
古き良きHTML Formを活用するためのServer Actions
次に、データ送信に関する話題を扱いましょう。React Server Componentsは、データの取得とレンダリングだけではなく、データの送信についてもサポートしています。ブラウザからNext.jsサーバーへのデータ送信については、HTML Form(<form> 要素)を使うことが想定されており、特別な専用のAPIは用意されていません。ただ、少し独特な使い方をするので、少しずつ見ていきましょう。
Server ActionsにFormDataを渡す
サンプルとしてTodoアプリを作成します。まずは、タスクの表示と追加ができるようにしましょう。図1のような画面を作成します。
ソースコードはリスト6のように作りました。サンプルコードではclassNameにCSSを当てておりますが、記事中のサンプルでは割愛させてください。
import { revalidatePath } from "next/cache";
import { getTodoList, addTodoItem } from "@/app/db";
export default async function TodoList() {
const todoList = await getTodoList(); // (1)
// (4)
async function createTask(formData) {
"use server"; // (5)
const title = await formData.get("title"); // (6)
await addTodoItem(title); // (7)
revalidatePath("/todos"); // (8)
}
return (
<main>
<h1>Todo App</h1>
<h2>新しいタスクを入力する</h2>
{/* (3) */}
<form action={createTask}>
<input type="text" name="title" />
<button type="submit">追加</button>
</form>
{/* (2) */}
<h2>タスク一覧</h2>
<div>
{todoList.map((item) => (
<div key={item.id} >
<p>{item.title}</p>
</div>
))}
</div>
</main>
);
}
まず、タスク一覧で扱うためのデータは(1)のように取得しています。サンプル用の簡単なものですが、通信だとお考えください。取得したデータは(2)で並べています。
さて、次は(3)のフォームを見ていただきましょう。input要素とsubmitボタンがひとつずつある、素直なHTML Formですが、一箇所だけ奇妙な点があります。action 属性に、createTask という関数を指定しているのです。このように設定することで、submit時のサーバー側でデータハンドリングを行うための関数を定義できます。もちろん、普通のHTMLではこんな書き方はできません。React Server Componentsによる、特殊な前処理によって、ブラウザで描画される時点では普通のHTML Formに変換されます。
それでは次に、action で指定された(4)の関数を見てみましょう。この関数はReact Server Componentsの一機能でServer Actionsと呼ばれています。まず目につくのは(5)の "use server" という文字列です。これはディレクティブと呼ばれる宣言で、この関数がサーバー側で実行されることを表します。次に、(6)で引数からデータを取り出します。Server Actionsの関数には、フォームから送信された FormData オブジェクトが引数として渡されるので、データを取り出してから、(7)のように別のサーバーなどに送信します。
最後に、表示中のデータに更新すべき変更があったことをNext.jsへと知らせるために、revalidatePath() を実行しています。この画面は /todos というパスで表示されているので、引数もそれに合わせました。
これで、フォームにタスク名を入力して送信ボタンを押すと、タスク一覧が更新される、という挙動が実現できました。
Server Actionsにパラメータをバインドする
さて、action に関数を指定するだけで、サーバー側でFormDataを処理できることはわかりました。では、少し別の例を考えます。図2のような削除ボタンはどう実装すればよいでしょうか。
古き良きHTMLの素直な実装としては、リスト7のように type="hidden" なinput要素を使って、削除対象のタスクIDをフォームに含めて送信するのが一般的です。
// (略)
{todoList.map((item) => (
<div key={item.id}>
<p>{item.title}</p>
<form action={deleteTask}>{/* (2) */}
{/* (1) */}
<input type="hidden" name="id" value={item.id} />
<button>削除</button>
</form>
</div>
))}
// (略)
ただ、(1)のようにhiddenを使う方法だと、タスクIDが(2)で指定した deleteTask() に渡る過程で、一度フォームを通ったことにより、文字列に変換されます。もしタスクIDがnumber型だった場合、numberのままサーバーに届いた方が嬉しいですよね。また、パラメータがDOMツリーに表示されるので、ユーザーに見せたくない情報を隠したいときにはこの方法は使えません。
そこで、少し特殊な方法を使います。リスト8を見てください。
// (略)
async function deleteTask(id) { // (2)
"use server";
await deleteTodoItem(id);
revalidatePath("/todos");
}
// (略)
{todoList.map((item) => (
<div key={item.id}>
<p>{item.title}</p>
{/* (1) */}
<form action={deleteTask.bind(null, item.id)}>
<button>削除</button>
</form>
</div>
))}
// (略)
(1)のように、deleteTask に関数の bind() 機能を使って、タスクIDをバインドしています。このようにすることで、deleteTask() にはタスクIDが引数として渡されます。この方法を使うと、フォームにパラメータを含めることなく、型も変わっていない状態で(2)のようにパラメータを受け取ることができます。
最終的には、図3のようになります。
また、最終的なソースコードはリスト9のようになりました。
import { revalidatePath } from "next/cache";
import { getTodoList, addTodoItem, deleteTodoItem } from "@/app/db";
export default async function TodoList() {
const todoList = await getTodoList();
async function createTask(formData) {
"use server";
const title = await formData.get("title");
await addTodoItem(title);
revalidatePath("/todos");
}
async function deleteTask(id) {
"use server";
await deleteTodoItem(id);
revalidatePath("/todos");
}
return (
<main>
<h1>Todo App</h1>
<h2>新しいタスクを入力する</h2>
<form action={createTask}>
<input type="text" name="title" />
<button type="submit">追加</button>
</form>
<h2>タスク一覧</h2>
<div>
{todoList.map((item) => (
<div key={item.id}>
<p>{item.title}</p>
<form action={deleteTask.bind(null, item.id)}>
<button>削除</button>
</form>
</div>
))}
</div>
</main>
);
}
まとめ
3回にわたって、App Routerの独特の世界観について触れてきました。初めは見慣れない記法に目が泳ぎますが、慣れてくると直感的に通信とUIを統合できる、良いフレームワークに見えてきます。ぜひ、実際に手を動かしてみてください。
