【Next.js 15】cache実装が大幅改良される話

2024/10/27に公開

はじめに

今、Next.js 15がホットな話題ですが、そのNext.js 15で導入された、use cahceにより、これまでのNext.jsのcache戦略が大きく改良されることになりました。

また、よりシンプルかつ柔軟なcache戦略を取り入れやすくなり、大幅に開発体験が改良されることになるであろうため、use cacheについて以下の記事を参考に解説しようと思います。

https://nextjs.org/blog/our-journey-with-caching

Next.js 14までの課題

1. fetch()について

これまでNext.jsのfetch()といえば、デフォルトでcacheされるものでした。
しかしながら、これはfetch()を使用しない場合を、十分考慮したものではありませんでした。

そのため、ローカルデータベースアクセスに対する制御は十分に提供されていないといった課題がありました。

2. fetch()以外を使用する場合、エスケープハッチが必要だった

エスケープハッチというのはハック的な方法で課題を解決することを言います。

これまでのNext.jsの場合、セグメントレベル(page.tsxやlayout.tsxなど)でのconfig設定やexport const dynamicruntimefetchCachedynamicparamsrevalidate = など、何かしらの方法でハックするしかありませんでした。
※ Next.js 15でも、下位互換性のために、これらはサポートが続けられます

これらがNext.jsが難しいと言われている要因のほとんどで、これらを適切に理解するのですら大変なのです。
そこから、使いこなせるようになるのはもっと大変で、とにかく、cacheの最適化をするのが非常に難しいと言わざるを得ませんでした。

しかし、なんと、これからは、これらをすべて理解する必要がなくなります!
それに、以下の記事いわく、忘れて良いとのことです!
https://nextjs.org/blog/our-journey-with-caching

ということで、これからのcacheについて詳細に見ていこうと思います。

Next.js 15の場合

概要

まず、概要ですが、今後、cacheを使用する場合は、ページの先頭にuse cacheを宣言するだけでよくなります。

page.tsx
"use cache"
 
export default async function Page() {
  return fetch(...)
}

詳細な変更点については、以下にまとめます。

変更点

1. fetch()はデフォルトでcacheしなくなる

上記、概要の通りで、use cahceディレクティブを使用して、cacheすることになるため、これまで、「fetch()のデフォルトの挙動を変えるのはどうなの?」と言われていた問題が解決することになります。
これにより、fetch()以外の方法も十分サポート可能となることが予想されます。

2. ページの先頭に use cache を付けると、静的ページを構成する

これまではfetch()を使用したコンポーネントを以下のように、Suspenseで囲む必要がありました。

page.tsx
async function Component() {
  return fetch(...)
}
 
export default async function Page() {
  return <Suspense fallback="..."><Component /></Suspense>
}

しかし、概要にもある通りで、今後はSuspenseが不要となります。
これは、取得した全データをcacheするためです。

page.tsx
"use cache"
 
export default async function Page() {
  return fetch(...)
}

3. 部分的に静的コンテンツとすることが可能

まず、rootのlayout.tsxではuse cacheを宣言します。
そして、動的コンテンツとしたいものについてはuse cahceを宣言しないことで実現できます。

なので、基本的な方針はSSG・SSRと変わらず、use cahceを基本的に宣言することをデフォルトとして、動的コンテンツとしたい場合に宣言しないという運用になるかと思います。

layout.tsx
"use cache"
 
export default async function Layout({ children }) {
  const response = await fetch(...)
  const data = await response.json()
  return <html>
    <body>
      <div>{data.notice}</div>
      {children}
    </body>
  </html>
}
page.tsx
import { Suspense } from 'react'
async function Component() {
  return fetch(...)
}
 
export default async function Page() {
  return <Suspense fallback="..."><Component /></Suspense>
}

4. 関数のcache

関数についてもcache可能で、ここでもcacheしたい関数にuse cacheを宣言するだけでよくなります。

また、関数のcacheについてはcache keyが不要が自動設定となるため、自分たちで設定・管理する必要がなくなります。
これは開発体験の向上に大きいと思います。

また、JSX内でawaitによる関数呼び出しができるようにもなるようで、将来的には、このような記述が主流になるのかもしれません。

layout.tsx
async function getNotice() {
  "use cache"
  const response = await fetch(...)
  const data = await response.json()
  return data.notice;
}
 
export default async function Layout({ children }) {
  return <html>
    <body>
      <h1>{await getNotice()}</h1>
      {children}
    </body>
  </html>
}

5. タグ付け

cache keyは不要になりますが、自分たちで管理する必要のあるcacheも存在することがあります。
その場合は、cacheTag()にてcacheにタグ付けを行うことで管理することができます。

layout.tsx
import { cacheTag } from 'next/cache';
 
async function getNotice() {
  'use cache';
  cacheTag('my-tag');
}

タグ付けしたcacheは従来のようにrevalidateTag()server actionから呼び出してパージすることができます。

また、以下のように複数のタグを配列で単一のキャッシュエントリに割り当てることもできるようです。

cacheTag(['tag-one', 'tag-two'])

https://nextjs.org/docs/canary/app/api-reference/functions/cacheTag

6. 特定のcacheのパージ

上記のタグ付けですが、具体的には、以下のようなケースで使用することが想定されているようです。

postのリストをfetchする関数でuse cacheを宣言すると、そのリストが更新されません。
ただし、以下のように識別子を用いて、タグ付けすることで、編集したデータについてのみ、更新を行うserver actionrevalidateTagを用いることでcacheをパージ・更新することができます。

import { unstable_cacheTag as cacheTag } from 'next/cache';
 
async function getBlogPosts(page) {
  'use cache';
  const posts = await fetchPosts(page);
  for (let post of posts) {
    cacheTag('blog-post-' + post.id);
  }
  return posts;
}

7. cahceの再検証

cacheLife()により、cacheに有効期限をつけられ、任意のタイミングでrevalidateすることもできます。

やっていることとしては、ISRとほとんど同じで、さしづめ、ISRのcache版といった感じですね。

要するに、SWR(Stale-While-Revalidate)と同じで、revalidate(cache再検証)している間は古いcahceを返却するということができるということです。

※ SWR(Stale-While=Revalidate)についての詳細は以下の記事がわかりやすいと思います
CloudFrontで学ぶstale-while-revalidate、stale-if-errorディレクティブ

設定値としては、以下が設定できるようです。

  • "seconds"
  • "minutes"
  • "hours"
  • "days"
  • "weeks"
  • "max"
page.tsx
"use cache"
import { unstable_cacheLife as cacheLife } from 'next/cache'
 
export default async function Page() {
  cacheLife("minutes")
  return
}

ユースケースに最適な大まかな範囲を選択します。正確な数値を指定して、1 週間に何秒 (または何ミリ秒) があるかを計算する必要はありません。ただし、特定の値を指定したり、独自の名前付きキャッシュ プロファイルを構成したりすることもできます。

上記が仕様のようで、カスタム値も設定できるようです
ドキュメントにカスタム値の細かい設定方法が記載してありました。

https://nextjs.org/docs/canary/app/api-reference/next-config-js/cacheLife

next.config.jsdynamicIOフラグを有効にし、cacheLifeに対してオブジェクトを定義して使用するようです。

next.config.js
module.exports = {
  experimental: {
    dynamicIO: true,
    cacheLife: {
      blog: {
        stale: 3600, // 1 hour
        revalidate: 900, // 15 minutes
        expire: 86400, // 1 day
      },
    },
  },
}

stalerevalidateexpireというキーは、それぞれ以下の役割があります。

Property Value Description Requirement
stale number クライアントがサーバーをチェックせずに値をキャッシュする期間 Optional
revalidate number サーバー上でキャッシュを更新する頻度。再検証(revalidate)中に古い値が提供される場合がある。 Optional
expire number 動的フェッチ(dynamic fetching)に切り替わる前に値が古いままでいられる最大期間。revalidateより長くする必要があります。 Optional

動的フェッチ(dynamic fetching)に切り替わる前に値が古いままでいられる最大期間。revalidateより長くする必要があります。

上記のexpireがドキュメントを見ても、理解が難しいところだと思うので、解説します。
そのためには、まず、revalidateのおさらいをする必要があります。

revalidateはキャッシュを再検証(更新)するタイミングや頻度を示すものです。
簡単に言うと、キャッシュを使い続けるために、サーバーにチェックしにいくタイミングといえます。
つまり、この設定により、定期的にキャッシュが更新され、サーバーとのデータ整合性が保たれます。

ここまでで、revalidateについて理解できたと思います。
このcacheLifeにおける、revalidateを理解すると、「relivadaあるなら、expire必要?」という疑問が発生することと思います。

結論を言うと、expirerevalidateの保険のようなものです。
だからこそ、revalidateより設定値を長くする必要があります。

expireがあることで、仮にrevalidateが発生せず、キャッシュが更新されない場合でも、クライアントとサーバー間のデータ整合性を保つことができます。
なぜなら、expireにはクライアントがキャッシュによる古いデータを使用し続けられる最大期間を設定するためです。

expireの期限を過ぎたら、強制的にキャッシュは再検証されます。
これがあることで、ISRの挙動をを確実に担保することができます。

以下のように、cacheLifeの引数にnext.configで定義したオブジェクト名を渡すだけでカスタム値を使用できます。

action.ts
import { unstable_cacheLife as cacheLife } from 'next/cache'
 
export async function getCachedData() {
  'use cache'
  cacheLife('blog')
  const data = await fetch('/api/data')
  return data
}

以上が、大まかな機能のようです。

まとめ

これまでNext.jsはcache周辺の機能の学習コスト・導入コストが非常に高く、そこが一番のハードルと思っていたので、これはありがたいアップデートだなと個人的には思いました。

これで、日本でも、Next.jsがより広まれば尚良しという感じですね!

参考文献

https://nextjs.org/blog/our-journey-with-caching
https://nextjs.org/docs/canary/app/api-reference/functions/cacheTag
https://zenn.dev/devcamp/articles/e9877f79230ef2
https://nextjs.org/docs/canary/app/api-reference/next-config-js/cacheLife

Discussion