Dynamic IOの成り立ちと"use cache"の深層
"use cache"は、Next.jsのDynamic IOで利用することができる新たなディレクティブです。本稿は筆者の備忘録を兼ねて、Dynamic IOの成り立ちや"use cache"の内部実装について解説するものです。
Dynamic IOの成り立ち
Dynamic IOは2024年10月のNext Confで発表された、Next.jsにおける新しいコンセプトを実証するための実験的モードです。Dynamic IOはその名の通り、主に動的I/O処理に対する振る舞いを大きく変更するものです。
具体的には、以下の処理に対する振る舞いが変更されます。
- データフェッチ:
fetch()やDBアクセスなど -
Dynamic APIs:
headers()やcookies()など - Next.jsがラップするモジュール:
Date、Math、Node.jsのcryptoモジュールなど - 任意の非同期関数(マイクロタスクを除く)
Dynamic IOでこれらを扱う際には<Suspense>境界内に配置、もしくは"use cache"でキャッシュ可能であることを宣言する必要があります[1]。
-
<Suspense>: 動的にデータフェッチを実行する場合、対象のServer Componentsを<Suspense>境界内に配置します。従来同様<Suspense>境界内はStreamingで配信されます。 -
"use cache": データフェッチがキャッシュ可能な場合、"use cache"を宣言することで、Next.jsにキャッシュ可能であることを指示します。
Partial Pre-Rendering(PPR)を理解してる方であれば、Static RenderingとDynamic Renderingが1つのページで混在し<Suspense>境界単位でレンダリングを分離していく設計については馴染み深いことでしょう。

Dynamic IOではさらにこれを発展させ、Dynamic RenderingにStatic Renderingを入れ子にすることが可能となります。
export default function Page() {
return (
<>
...
{/* Static Rendering */}
<Suspense fallback={<Loading />}>
{/* Dynamic Rendering */}
<DynamicComponent>
{/* Static Rendering */}
<StaticComponent />
</DynamicComponent>
</Suspense>
</>
);
}
Dynamic IOではレスポンスの開始がデータフェッチによってブロックされることがないため、効率的な配信が可能となります。
私見: キャッシュの混乱とNext.jsへの評価
Dynamic IOは現状実験的モードですが、未来のNext.jsのあり方の1つとも考えられます。従来のNext.jsのデフォルトで強力なキャッシュは、開発者に多くの混乱をもたらしました。Dynamic IOにおいて開発者は、<Suspense>境界内で常に実行するか"use cache"でキャッシュ可能にするか明示的に選択することになるため、従来の混乱は解消されると考えられます。
キャッシュの複雑さはNext.jsに対する最も大きなネガティブ要素だったと言っても過言ではありません。Dynamic IOの開発が進むにつれ、Next.jsに対する評価も大きく改める必要があるのではないかと筆者は考えています。
"use cache"
"use cache"はDynamic IOにおける最も重要なコンセプトです。"use cache"はファイルや関数の先頭につけることができ、Next.jsはこれにより関数やファイルスコープがキャッシュ可能であることを理解します。
// File level
"use cache";
export default async function Page() {
// ...
}
// Component level
export async function MyComponent() {
"use cache";
return <></>;
}
// Function level
export async function getData() {
"use cache";
const data = await fetch("/api/data");
return data;
}
"use cache"は引数や参照してる変数などを自動的にキャッシュのキーとして認識しますが、childrenのようなキーに不適切な一部の値は自動的に除外されます。
より詳細に知りたい方は、以下公式ドキュメントを参照ください。
キャッシュの永続化
"use cache"によるキャッシュは、内部的に以下2つに分類されます。
- オンデマンドキャッシュ[2]: オンデマンド(
next start以降)で利用されるキャッシュ -
ResumeDataCache: PPRのPrerenderから引き継がれるキャッシュ
オンデマンドキャッシュは、現時点ではシンプルなLRUのインメモリキャッシュです。内部的にはCacheHandlerという抽象化がされており、将来的には開発者がカスタマイズ可能になることが示唆されています。
ResumeDataCacheはPPRのPrerenderから引き継がれる特殊なキャッシュで、現時点ではCacheHandlerとは別物になっています。
"use cache"の内部実装
以降は"use cache"がどう実現されているのか、Next.jsの内部実装について解説します。
"use cache"は大まかに以下のような仕組みで実現されています。
- SWC Pluginで
"use cache"対象となる関数を、Next.js内部定義のcache()を通して定義する形に変換する -
cache()は引数にキャッシュのIDや元となる関数などを含み、このIDなどをもとにキャッシュの取得や保存を行う
"use cache"に対するトランスパイル
Next.jsアプリケーションはSWCでトランスパイルされ、Server Actionsをマークする"use server"に対してはSWC Pluginによって独自の変換処理が適用されます。"use cache"の変換も、このSWC Pluginに含まれる形で実装されています。
"use server"用のPluginで"use cache"に関する処理をしているのはだいぶ違和感がありますが、実験的モードですし実装速度を優先したのかもしれません。
以下はfunction() {}の先頭に"use cache"があった時の処理です。if let Directive::UseCache { cache_kind } = directive { ... }で"use cache"を判定しています。
この処理内でself.maybe_hoist_and_create_proxy_for_cache_function()が呼ばれることで、self.has_cache = trueとなります。ここで設定されたself.has_cacheにより、以下の分岐に入ります。
ここでは対象コードのアウトプットであるnewに対し、コメントにもあるようなimport { cache as $$cache__ } from "private-next-rsc-cache-wrapper";が挿入されるような処理がされています。
さらにself.maybe_hoist_and_create_proxy_for_cache_function()の後続処理で、対象のfunction() {}に対しexport var {cache_ident} = ...の形に変換処理が適用されます。
上記のinitで呼ばれるwrap_cache_expr()にて、対象のfunction() {}は$$cache__("name", "id", 0, function() {})のような形に変換されます。
これらの処理により、"use cache"の対象関数はprivate-next-rsc-cache-wrapperのcache()関数を介して定義される形に変換されます。
その他にもいくつか処理はありますが、残り部分の処理は割愛します。これらの処理を経て最終的には以下のような入出力が得られます。
"use cache";
import React from "react";
import { Inter } from "@next/font/google";
const inter = Inter();
export async function Cached({ children }) {
return <div className={inter.className}>{children}</div>;
}
/* __next_internal_action_entry_do_not_use__ {"c0dd5bb6fef67f5ab84327f5164ac2c3111a159337":"$$RSC_SERVER_CACHE_0"} */ import { registerServerReference } from "private-next-rsc-server-reference";
import {
encryptActionBoundArgs,
decryptActionBoundArgs,
} from "private-next-rsc-action-encryption";
import { cache as $$cache__ } from "private-next-rsc-cache-wrapper";
import React from "react";
import inter from '@next/font/google/target.css?{"path":"app/test.tsx","import":"Inter","arguments":[],"variableName":"inter"}';
export var $$RSC_SERVER_CACHE_0 = $$cache__(
"default",
"c0dd5bb6fef67f5ab84327f5164ac2c3111a159337",
0,
/*#__TURBOPACK_DISABLE_EXPORT_MERGING__*/ async function Cached({
children,
}) {
return <div className={inter.className}>{children}</div>;
},
);
Object.defineProperty($$RSC_SERVER_CACHE_0, "name", {
value: "Cached",
writable: false,
});
export var Cached = registerServerReference(
$$RSC_SERVER_CACHE_0,
"c0dd5bb6fef67f5ab84327f5164ac2c3111a159337",
null,
);
private-next-rsc-cache-wrapper
上記の変換で挿入されたprivate-next-rsc-cache-wrapperは、webpackのaliasです。
上記パスは以下のファイルのbuild結果で、use-cache-wrapper.tsよりcache()をそのままexportしています。
このcache()関数こそが、"use cache"の振る舞いを実装している部分になります。
cache()
cache()関数は数百行程度ありますが、ここでは特にキャッシュの永続化の仕組みについて確認します。キャッシュの永続化は前述の通り以下2つに分けられています。
- オンデマンドキャッシュ(
CacheHandler由来) ResumeDataCache
オンデマンドキャッシュ(CacheHandler由来)
CacheHandlerは以下で定義されます。
設定可能なCacheHandlerのように見えますが、導入時のPRや筆者が実装を確認した限りでは設定できるような手段は見つかりませんでした。そのため、現状は必ずDefaultCacheHandlerが利用されるものと考えられます。なお、DefaultCacheHandlerはLRUのインメモリキャッシュです。
ResumeDataCache
ResumeDataCacheは、文字通りレンダリングをResume=再開するためのキャッシュです。これはPPR有効時に利用される共有キャッシュで、next build時に生成したキャッシュをそのままnext startで再利用することができます。
ResumeDataCacheは以下のPRで追加されました。
Prerender時にアクセスされなかった"use cache"な関数は、ResumeDataCacheではなくCacheHandlerでハンドリングされます。
キャッシュデータの実態
CacheHandlerで永続化されるキャッシュもResumeDataCacheも、データの実態はRSC Payloadです。つまり"use cache"を適用するとコンポーネントも関数も同様に、RSC Payloadとして内部的に処理されます。
"use cache"のキャッシュのキーや関数の戻り値はシリアル化可能であるという制約は、内部的にRSC Payloadで扱うことや、外部に保存することを考慮しての制約だと考えられます。
おおよそ"use cache"を実現するための実装が理解できたので、調査はここまでとしました。
感想
これまで筆者はNext.jsのキャッシュの仕組みに関する記事をいくつか執筆してきました。
上記の記事を執筆してる時に何度も、注意すべき制約が多いことが気がかりでした。今回"use cache"について調査している時には、利用者側の制約については少ないような印象を受けました。SWC Pluginによる実装は黒魔術やMagicと称され忌避されることも多いですが、実際利用する観点においてはシンプルに設計されていると思います。
従来のキャッシュに対するネガティブな意見は、デフォルト挙動に関するものが大きかったとは思います。Dynamic IOはシンプルな設計の上に成り立っているように感じており、調査を進めるほど期待感が高まりました。今後のDynamic IOの開発に期待したいところです。
Discussion
記事ありがとうございます!
=======
ここだけちょっとわからなかったので、質問してもよろしいでしょうか。
これを見る限り、dynamic IOのスコープはfetchのオプション + unstable_cacheを代替するもので、あまりレンダリングの考え方には影響ないのかなと、少なくとも自分は理解しました。
なので、DynamicComponentのなかにStaticComponentを入れ子にしたときに具体的にどのように挙動が変わるのか理解できませんでした。Static ComponentをIOキャッシュを伴うものと見ているかどうかが結構大きい気がしています。
また、Dynamic IOではフェッチによってブロックされることがないというのも、現状でできていることとの違いがわからなかったので、よければ詳しく教えてもらえないでしょうか。
=======
特に記事を直してほしいとかではなく単純な疑問です!返事を求めるのも本来厚かましいことと承知のうえですので、気が向いたらの回答で構いません!記事が興味深かったので質問でした!
質問ありがとうございます!
大晦日にご苦労様ですww
こちらについては「Our Journey with Caching」にて
とあるように、Dynamic IOの世界観では今までの世界観は一旦忘れて良いかと思います。
その上でDynamic IOのコンセプトを簡単に書き出すと「動的I/O処理を含む場合には遅延させる(
<Suspense>)かキャッシュする("use cache")必要がある」ということになります。これらの前提のもと、
こちらに回答すると、フェッチなどの動的I/O処理を扱うには「遅延させる(
<Suspense>)かキャッシュする("use cache")必要がある」ので、ページのレンダリングでフェッチを即座に扱うことができなくなりました。つまり新たにできることが増えたというより、できることを減らしてパフォーマンスを追求する形です。ちょっと語弊があるのですが少々極端にいうと、PPRを強制するような世界観のイメージです。一方、
こちらは新たにできるようになったことになります。従来はPPRによって「静的コンポーネントに動的コンポーネントを埋め込む」ことはできたのですが、「動的コンポーネントに静的コンポーネントを埋め込む」ことはできませんでした。これはキャッシュのオプトインが
"use cache"ディレクティブによってするような設計になったことで、新たにできるようになったものです。以下に例を挙げてみます。ユーザー情報を含み、動的にレンダリングする必要のある
<Cart>内で、<ProductCard>のようなキャッシュ可能なコンポーネントを埋め込むことなどができるようになったというイメージです。以上になります。
知りたきことに対する回答になってますかね...?
sumirenさんが認識してる「現状でできていること」と僕が認識してる「現状でできていること」に齟齬があって変な回答になってないか少し心配です。
↑の回答で意味がわからないという部分があれば、この「現状でできていること」も併せてご教示いただけると幸いです。
ありがとうございます!!
あぁ、たしかに!あまりやることがないので忘れてましたが、たしかにいままでは同期でfetchを扱うことで非Streamingでのレンダリングも可能でしたね!それがStreaming or Staticの二択になったのは確かに1つのレンダリング上の変化ですね。
この記載は、上記の制約を指しているであってますかね!
なるほど。これって今までできてなかったでしたっけ...?動くけどPPRはできなかったということですかね?自分の現状に対する理解は以下です!
補足ありがとうございます!
多分ここに細かいニュアンスの齟齬がありそうな気がします👀
従来はFull Route Cache=Route全てをキャッシュするか、Data Cache=fetch単位でキャッシュするかしかなかったんですが、Dynamic IOでは
"use cache"をコンポーネント単位で付与できるようになりました。これは従来できなかったことだと思います。僕の実装例で言うと、
<Cart>内の<ProductCard>の内容がキャッシュ可能だったとしても、従来は↓のようにfetchにキャッシュを指定する必要がありました。この場合
"render <ProductCard>"は<Cart>がレンダリングされるたびに出力されました。これがDynamic IO以降は以下になります。
この場合
"render <ProductCard>"は<Cart>がレンダリングされるたびに出力されません。この
<ProductCard>がrevalidateされるまでずっとコンポーネントレベルでキャッシュされます。ここが従来との違いになります!
いかがでしょうか...?
ありがとうございます!長くやりとり継続いただいていてすみません!できる範囲で回答お願いします!
なるほど、ようやく理解できました!言い方に違いはあるかもしれませんが、以下のように理解しております。
ちなみに記事の下記の例はコンポジションになっていますが、コンポジションでも現状は実行時のツリー上にSuspenseの下にいたら動的にrenderされる認識でいらっしゃいますかね?(自分の直感が誤っているかもなので、検証してみようかなと思います)(もはやDynamic IOにベットするのだから現状の挙動は気にしなくて良い感もありますが)
export default function Page() {
return (
<>
...
{/* Static Rendering /}
<Suspense fallback={<Loading />}>
{/ Dynamic Rendering /}
<DynamicComponent>
{/ Static Rendering */}
<StaticComponent />
</DynamicComponent>
</Suspense>
</>
);
}
はい、そうなります!
そして本記事で扱ってるように、
"use cache"によるキャッシュの永続化は従来とは異なるものになってます。こちらもおっしゃる通り...だと思います!
Our Journey with CachingでRoute Segment ConfigはEscape Hatchesと表現されており、今後は併用されない世界観を作っていくつもりなのであろうと予想しています。
最もこれは僕がそう読み解いてるだけで、直接明言されているわけではないので「おそらくそうだろう」という予想のお話ですが...
はい、Dynamic IO以前の世界(PPR有効時)では
<StaticComponents>は動的にレンダリングされて、回避する手段はなかった認識です。Dynamic APIs利用時にNext.jsはpostponeという特別なPromiseをthrowします。これはbuild時には永遠に解決されないPromiseです。このPromiseがthrowされたら
<Suspense>境界に対して動的レンダリングをマークしていくので、コンポジションにしてても<Suspense>境界単位が同一な<StaticComponents>は動的にレンダリングされるはずだと思います!experimental.dynamicIOを有効にしているのにRoute Segment Configを指定すると数ヶ月前には既にビルドでエラーになったはず全てのRoute Segment Configではないかもだけど少なくとも
export const dynamic = ...はそうだった記憶ですありがとうございます!
なるほど、たしかにレンダリング時にPromiseをthrowする仕組みを考えると、コンポジションなどのコードベース上での依存関係というよりは、実際に解決されるツリーでSuspense内のものは動的になりそうな気がしますね!腑に落ちました!
ありがとうございました!
こちら、ちょうど試しててexport runtime = edgeとかでも怒られてました!ありがとうございます!