🚚

Next.js 13 の cache 周りを理解する - fetchCache

2023/04/28に公開
4

Next.js 13 App Router の cache 周りを理解したい記事シリーズです。

  1. Automatic fetch() Request Deduping
  2. revalidate
  3. fetchCache ← この記事

Route Segment Config - fetchCache オプション

App Router では、Route Segment Configという仕組みで、レイアウトあるいはページから特定の変数を export すると動作をカスタマイズできます。前回の記事では、このオプションのひとつである revalidate について動作を確認しました。

しかし、Route Segment Config には他にもキャッシュに関係するオプションがあります。

それが fetchCache オプションです。

このオプションに関して、beta ドキュメント上には次の記載があります。

This is an advanced option that should only be used if you specifically need to override the default behavior.
DeepL 翻訳: これは高度なオプションであり、特にデフォルトの動作を上書きする必要がある場合にのみ使用する必要があります。

本当に必要な特別なケースでのみの利用を推奨されているようです。その前提を頭の片隅に残しつつ、実際にどういうオプションか見てみましょう。

Dynamic Functions と fetch()

fetchCache オプションを理解するためには、Dynamic Functions と呼ばれる関数群と fetch() の関係性を把握しておく必要があります。

Next.js 13 App Router では、Cookie や HTTP ヘッダ情報といった、リクエストスコープに紐づく情報を取得するための関数をいくつか提供しています。それらはドキュメント上では Dynamic Functions と呼ばれ、 cookies()headers() といった関数が該当します。

そして、これらを利用した場合 fetch() のデフォルトのキャッシュ挙動が変化します。

fetch() はデフォルトでは cache:force-cache と同等の挙動で、すべてのクエリをキャッシュします。しかし、Dynamic Functions が呼ばれると、同一リクエスト内での以降の fetch() は cache:no-store に切り替わり、キャッシュを利用しなくなります。(※fetch() の cache オプションに関しては、別の記事でも解説しています。)

実際に確認してみます。ネストしたレイアウト・ページで fetch() を実行しています。しかし、途中の Foo Layout 内での fetch() 実行前のタイミングで cookies() を呼び出しています。

  • Root Layout
    • Foo Layout (fetch()前に cookies() を呼び出し)
      • Bar Layout
        • Bar Page
FooLayoutのコード例
import { cookies } from "next/headers";

export default async function Layout({ children }: { children: React.ReactNode }) {
  // ★Dynamic Functions
  cookies();

  const res = await fetch("http://localhost:3001/sample-api?page=foo_layout");
  const { data } = await res.json();

  return (
    <div className="bg-gray-200 p-4 border-2 border-gray-400 rounded">
      Foo Layout : {data}
      {children}
    </div>
  );
}

すると、次のような描画結果となります。(確認のためリロードしています)

cookies() の呼び出しより上位階層ではキャッシュが利用されていないことがわかります。下位階層のコンポーネントから実行が開始され、今回のケースでは単純にfetch() の呼び出しも下位階層のほうが先となります。

本記事のサンプルでは動作がわかりやすいよう、各コンポーネントでの非同期処理は単一の fetch() のみとしているため、単純に下位階層のコンポーネントから順に fetch() が実行されます。しかし、実際のプロダクト開発では他の非同期処理が混ざったり、複数の fetch() 実行が含まれることも多く、実行順序は一概に保証されるものではないためご注意ください。

※(@koichikさんにご指摘いただきました。ありがとうございます!)

これが fetchCache 理解の前提となる Dynamic Functions と fetch() の挙動です。

fetchCache オプションのデフォルト動作

さて、Dynamic Functions と fetch() の関係を把握できましたが、この動作を制御するためのオプションが fetchCache です。

fetchCache には次のオプションを指定可能です。

  • 'auto'
  • 'default-cache'
  • 'default-no-store'
  • 'only-cache'
  • 'only-no-store'
  • 'force-cache'
  • 'force-no-store'

ひとつずつオプションの挙動を確認してみましょう。

fetchCache 各オプションの説明

⚠️ 注意事項

Next.js 13.3 で手元で動作を確認しましたが、一部ドキュメントの通りに動作しないようです。
(コードも見てみましたが、不具合かもしれない...)

以下説明は、ドキュメント上での記述を解説したものであり、Next.js 13.3 以前のバージョンでは安定的に動作しない可能性があります。
現状では「将来的にこういうオプションが使えるかも」程度の温度感で見ていただくほうが良さそうです。

fetchCache = 'auto' (default)

fetchCache に何も指定しなかった場合のデフォルトの挙動です。

  • fetch() に指定された cache オプションを利用する
  • fetch() に cache オプションが無い場合、Dynamic Functions の実行前後で挙動が変化する
    • 実行前の場合は force-cache 相当
    • 実行前の場合は no-store 相当

つまり、さきほどの Dynamic Functions と fetch() の動作の正体は、この 'auto' 指定によるものでした。

整理すると次のようになります。

fetch() - cache オプション Dynamic Functions キャッシュの利用
(指定なし) なし キャッシュする
force-cache なし キャッシュする
no-store なし キャッシュしない
(指定なし) 実行後 キャッシュしない
force-cache 実行後 キャッシュする
no-store 実行後 キャッシュしない

fetchCache = 'default-cache'

cache オプションを省略した場合、すべて force-cache 相当で動作させます。

  • fetch() に指定された cache オプションを利用する
  • fetch() に cache オプションが無い場合、Dynamic Functions の実行に関わらず force-cache 指定となる

※太字は 'auto' 指定との差異

fetch() - cache オプション Dynamic Functions キャッシュの利用
(指定なし) なし キャッシュする
force-cache なし キャッシュする
no-store なし キャッシュしない
(指定なし) 実行後 キャッシュする
force-cache 実行後 キャッシュする
no-store 実行後 キャッシュしない

Dynamic Functions の実行後でもデフォルトではキャッシュ対象になる点が 'auto' との大きい差異ですね。

一方で、cache オプションの指定自体は可能なので、任意箇所で no-store でキャッシュを無効化することは可能です。

fetchCache = 'default-no-store'

cache オプションを省略した場合、すべて no-store 相当で動作させます。

  • fetch() に指定された cache オプションを利用する
  • fetch() に cache オプションが無い場合、Dynamic Functions の実行に関わらず no-store 指定となる

※太字は 'auto' 指定との差異

fetch() - cache オプション Dynamic Functions キャッシュの利用
(指定なし) なし キャッシュしない
force-cache なし キャッシュする
no-store なし キャッシュしない
(指定なし) 実行後 キャッシュしない
force-cache 実行後 キャッシュする
no-store 実行後 キャッシュしない

'default-cache' がデフォルトを force-cache にするのに対して、その逆の動作になるイメージですね。

fetchCache = 'force-cache'

すべての fetch() リクエストをすべてキャッシュ対象とします。fetch() に no-store オプションが指定されたとしてもキャッシュ対象になります。

  • fetch() に指定された cache オプションは利用されない
  • fetch() に cache オプションが無い場合、Dynamic Functions の実行に関わらず force-cache 指定となる

※太字は 'auto' 指定との差異

fetch() - cache オプション Dynamic Functions キャッシュの利用
(指定なし) なし キャッシュする
force-cache なし キャッシュする
no-store なし キャッシュする
(指定なし) 実行後 キャッシュする
force-cache 実行後 キャッシュする
no-store 実行後 キャッシュする

fetchCache = 'force-no-store'

'force-cache' の逆で、すべてをキャッシュしません。fetch() に force-cache オプションが指定されたとしてもキャッシュされません。

  • fetch() に指定された cache オプションは利用されない
  • fetch() に cache オプションが無い場合、Dynamic Functions の実行に関わらず no-store 指定となる

※太字は 'auto' 指定との差異

fetch() - cache オプション Dynamic Functions キャッシュの利用
(指定なし) なし キャッシュしない
force-cache なし キャッシュしない
no-store なし キャッシュしない
(指定なし) 実行後 キャッシュしない
force-cache 実行後 キャッシュしない
no-store 実行後 キャッシュしない

とにかくキャッシュしません。

fetchCache = 'only-cache'

cache オプションを省略した場合、すべて force-cache 相当で動作させます。
また、cache オプションに no-store が指定された場合には fetch() がエラーとなります。

  • fetch() に cache オプションが無い場合、Dynamic Functions の実行に関わらず force-cache 指定となる
  • fetch() に指定された cache オプションが no-store の場合はエラーとなる

※太字は 'auto' 指定との差異

fetch() - cache オプション Dynamic Functions キャッシュの利用
(指定なし) なし キャッシュする
force-cache なし キャッシュする
no-store なし (エラー)
(指定なし) 実行後 キャッシュする
force-cache 実行後 キャッシュする
no-store 実行後 (エラー)

fetchCache = 'only-no-store'

cache オプションを省略した場合、すべて no-store 相当で動作させます。
また、cache オプションに force-cache が指定された場合には fetch() がエラーとなります。

'only-cache' の逆ですね。

  • fetch() に cache オプションが無い場合、Dynamic Functions の実行に関わらず no-store 指定となる
  • fetch() に指定された cache オプションが force-cache の場合はエラーとなる

※太字は 'auto' 指定との差異

fetch() - cache オプション Dynamic Functions キャッシュの利用
(指定なし) なし キャッシュしない
force-cache なし (エラー)
no-store なし キャッシュしない
(指定なし) 実行後 キャッシュしない
force-cache 実行後 (エラー)
no-store 実行後 キャッシュしない

というわけで fetchCache オプションの紹介でした。

only-no-store などは、決してキャッシュさせたくないことが明確なページなどで事故防止のために使えそうですね。

fetchCache オプションは現状ではまだ動作が怪しいですが、App Router が正式なものになった際には活用できるかもしれません。

GitHubで編集を提案
サイボウズ フロントエンド

Discussion

koichikkoichik

「Dynamic Functions と fetch()」の

(レンダリングの順番としては下位階層から描画されるため、

これはより正確には「下位階層のコンポーネントから実行が開始される」ですね

fetch() の呼び出しも下位階層のほうが先となります)

一方こちらはそうなるとは限りません

たとえばpage.tsxが最初に実行を開始しますが、それがもし非同期なコンポーネントでfetchを呼び出しているとレスポンスを受信するまで処理は中断します
するとその完了を待たずに上位階層のコンポーネント (layout.tsx) の実行が開始されます
それがまた非同期なコンポーネントであれば、やはりその完了を待たずに更に上位階層のコンポーネントの実行が開始され、、、
ということで記事中の例では最大4つのコンポーネントツリーが並行に実行 (レンダー) される可能性があります

さきほど別記事にコメントしたことにも関連しますが、並行にレンダーされるためにそれぞれのページやレイアウトのfetchがどのような順番で実行されるかは不確定になります (fetchで呼び出すAPIのレスポンスタイムに左右される)
その結果、あるDynamic Functions/fetchがどのfetchCache: 'auto'でデフォルトのfetchに影響するかも不確定になります
個人的にはautoは使用禁止にしたいと思っちゃいました

mugimugi

これはより正確には「下位階層のコンポーネントから実行が開始される」ですね

確かにそのほうが正しいですね。訂正しておきます🙏

それがまた非同期なコンポーネントであれば、やはりその完了を待たずに更に上位階層のコンポーネントの実行が開始され、、、
ということで記事中の例では最大4つのコンポーネントツリーが並行に実行 (レンダー) される可能性があります

すいませんこちら自分がまだ少し理解できていないかもなのですが、記事内でのサンプルのみを例にすると、単純にコンポーネントの構成が

  • Root Layout
    • Foo Layout (↑のchildren)
      • Bar Layout (↑のchildren)
      • Bar Page (↑のchildren)

となり、実行は下位階層からとなるため、それぞれに仕込んだ fetch() の呼び出し順序自体は Bar Page → Bar Layout → Foo Layout → Root Layout で固定になるのかなと思ったのですが、認識違うでしょうか?
それぞれの Layout や Page からまた別のコンポーネントツリーが生えたうえで、そこからfetch() が実行されていた場合には、それらを含めた実行順序は不確定になる、という理解をしています。
(このあたりは言われてみれば確かにという感じでした。ありがとうございます)

その結果、あるDynamic Functions/fetchがどのfetchCache: 'auto'でデフォルトのfetchに影響するかも不確定になります
個人的にはautoは使用禁止にしたいと思っちゃいました

確かにそうですね..
実行ごとにキャッシュの有無が切り替わる可能性が出てくるので、問題発生時などに特定も困難になりそうですね。

koichikkoichik

それぞれに仕込んだ fetch() の呼び出し順序自体は Bar Page → Bar Layout → Foo Layout → Root Layout で固定になる

記事内のサンプルそのままの話であれば確かにfetch()の呼び出し順は固定ですね (少なくとも今のNext.jsの実装では)
それはそれぞれのコンポーネントが呼び出すfetch()が一つだけ、かつ唯一の非同期操作だからです
しかし、Dedupeの記事でコメントしたようにfetch()の前にsetTimeout()を入れるだけでも呼び出し順は変わり得ます
たとえば記事内のサンプルでBar PageをsetTimeout()で少し待機してからfetch()を呼び出すようにすると、そのfetch()は他のLayoutのfetch()より後に実行される可能性があります (というか自分の手元では実際にそうなりました)

それが前のコメントで

記事中の例では最大4つのコンポーネントツリーが並行に実行 (レンダー) される

と書いた話になります

これは、たとえばBar Layoutに渡されるchildrenはBar Pageが返すReactElementそのものではない、というのがポイントかなと思っていて、つまり (少なくとも直接的には)

  • Bar Layout (↑のchildren)
    • Bar Page (↑のchildren)

ではないということです
実際にBar Layoutに渡されるchildrenは別のコンポーネントツリーへの参照のようなもので、ReactElementtype"div"などHTML要素を表す文字列でもコンポーネントを示す関数でもなく、Proxyオブジェクトが設定されたものになっています
このProxyReactElementにより、Bar LayoutはBar Pageの完了を待つことなく実行を開始することができるのだと理解しています
同様にFoo LayoutはBar Layoutの完了を、Root LayoutはFoo Layoutの完了を待つことなくそれぞれ実行を開始することができます
つまり記事中のサンプルでは最大で4つのコンポーネントツリーが並行にレンダーされます
このように並行で実行されるため、それぞれのコンポーネントツリーにおける最初の非同期操作以外はどのような順序で実行されるかは不確定というのが前のコメントに書いたことになります

これはこの記事のサンプルへのコメントとしては少々行きすぎだったかもしれませんね
しかしながら、この記事の読者が (前のコメントをした時点のように) 下位層のコンポーネントから逐次的に実行されるというイメージを持ってしまうと、たとえばBar Pageが複数のfetch()を呼び出していた場合の挙動にビックリすることになるかもしれないので、ちょっと気になったのでした

mugimugi

ありがとうございます!理解できたと思います。

おそらく私自身の認識はご説明頂いた内容と一致していたと思うのですが、記事内での説明では「今回のサンプルでは」という暗黙的なコンテキストが含まれていたので、常に下位レイヤから順に実行されてしまうような理解をされてしまう可能性がある文章になっていましたね。
そのあたり誤解を招かないよう、本文内に追記しておこうかと思います!

これはこの記事のサンプルへのコメントとしては少々行きすぎだったかもしれませんね
しかしながら、この記事の読者が (前のコメントをした時点のように) 下位層のコンポーネントから逐次的に実行されるというイメージを持ってしまうと、たとえばBar Pageが複数のfetch()を呼び出していた場合の挙動にビックリすることになるかもしれないので、ちょっと気になったのでした

いえ、たしかにミスリードを招く可能性があった点かなと思ったので、ご指摘ありがたいです!