Next.jsでCSR/SSRのアプリケーションを作っていると、APIのキャッシュ戦略も必要となる場面があります。
このときの手段としては以下のようなものがあるかと。
- 一覧データをRecoilやJotaiなどの状態管理ライブラリを使って実現する
- バックエンド側でキャッシュしてしまう(CDN、HTTP キャッシュ、Redisなど)
- fetchやライブラリのキャッシュ実装を使う
フロントエンドよくわからない私からすると、状態管理ライブラリを使うとコンポーネントと責務を分割できるとはいえ、接合点がごちゃごちゃになってライブラリの依存性が高くなりがちだと思っています。(単に複雑になりやすいからわから使いこなせない)
なので、バックエンドでなるべくキャッシュしてしまって、フロントではAPIの実行可否にかかわらず解決することを先に考えると思います。
ただし、素の fetch
で頑張るとなると、今度はフロントエンド側も少し実装を工夫する必要が出てきます。
かといってバックエンドにキャッシュ機構を作ろうと思ってもやっぱりちょっと面倒なので、中間対策としてキャッシュをいい感じに扱えるライブラリがあればいいなというのが初回の選択になりがちな気がしています。
そこでNext.jsのキャッシュ管理として筆頭に上がりそうなのが SWR です。
実装面でもあまり深くキャッシュについて考えなくてもよくて、とりあえずキー(APIのエンドポイント)さえ管理できていれば他の画面からも適当に mutate(エンドポイント)
してしまえば勝手に更新してくれるお手軽さがあります。
自分でもよくわからなかったので動作のわかりやすいNext14の簡単なアプリを作って検証してみました。
まず素のNext.jsでPage Routerのアプリを作ります。選択肢は Would you like to use App Router? (recommended)
のみ必ず NO
を選択してください。それ以外はなんでもOKです。
npx create-next-app@latest
✔ What is your project named? … my-app
✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like your code inside a `src/` directory? … Yes
✔ Would you like to use App Router? (recommended) … No
✔ Would you like to use Turbopack for `next dev`? … Yes
✔ Would you like to customize the import alias (`@/*` by default)? … No
Creating a new Next.js app in /xxxxx/my-app.
ディレクトリ直下で以下を実行し、swrを入れます。yarnを使ってますが、npmでもpnpmでも大丈夫です。
yarn add swr
次に、適当にHeaderコンポーネントを追加してみましょう。ChatGPTにお願いして生成してもらいつつ useSWRImmutable
を追加してみました。
import Link from 'next/link';
import Image from 'next/image';
import useSWRImmutable from 'swr/immutable';
const Header = () => {
const { data, error } = useSWRImmutable('/api/user/avatar', fetcher);
console.log('render Header');
return (
<header className="w-full py-4 px-6 bg-white shadow-md">
<nav className="max-w-7xl mx-auto flex justify-between items-center">
<Link href="/" className="text-xl font-bold">
My App
</Link>
<div className="flex gap-6">
<Link href="/" className="hover:text-gray-600">
Home
</Link>
<Link href="/about" className="hover:text-gray-600">
About
</Link>
<Link href="/contact" className="hover:text-gray-600">
Contact
</Link>
</div>
<div className='flex justify-end items-center'>
{error ? (
<Image src="/globe.svg" alt="Default Avatar" width={32} height={32} />
) : (
<Image src={data ? data.avatarUrl : '/globe.svg'} alt="User Avatar" width={32} height={32} />
)}
</div>
</nav>
</header>
);
};
const fetcher = (url: string) => fetch(url).then(res => res.json());
export default Header;
次にAPIを作ります。Next.jsのルール通り src/pages/api/user/avatar.ts
というディレクトリ・ファイル構成にします。
import { NextApiRequest, NextApiResponse } from 'next';
export default function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
// 外部サービスから仮の画像を2つほどランダムで取得
const urls = [
'https://image.lgtmoon.dev/316618',
'https://image.lgtmoon.dev/260085',
'https://image.lgtmoon.dev/290299',
'https://image.lgtmoon.dev/199372'
]
const random = Math.floor(Math.random() * urls.length);
const avatarUrl = urls[random];
res.status(200).json({ avatarUrl });
}
if (req.method === 'POST') {
// アバターを更新する処理。検証用なのでステータス200を返すだけ
return res.status(200).json({ success: true });
}
// サポートされていないメソッド
res.status(405).json({ error: 'Method not allowed' });
}
画像はローカルのでも良かったんですが、外部サービスとして私がよくPRにLGTMをするために使っている LGTMoon を使いました。
useSWRImmutable
はSWRが提供しているもので、キャッシュを無効にするためのエイリアスみたいなものです。
これにより、リロード以外では画像が更新されなくなります。
次に index.tsx
に、画像を更新する仕組みを仮実装します。
export default function Home() {
// Update Avatarボタン押下時の処理
const handleChangeAvatar = async () => {
console.log('change avatar');
const response = await fetch('/api/user/avatar', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ avatarUrl: '/globe.svg' }),
});
if(!response.ok) {
console.error('Failed to update avatar');
return;
}
//const updatedData = await response.json();
mutate('/api/user/avatar');
}
return (
<div
className={`${geistSans.variable} ${geistMono.variable} grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]`}
>
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
(略)
<button
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
onClick={handleChangeAvatar}
>
Update Avatar
</button>
(略)
</main>
(略)
ボタンを押したら handleChangeAvatar
が実行されるだけの簡単な実装です。
mutate('/api/user/avatar');
をするだけで、キー(URL)と一致するSWRのデータを更新してくれます。これは useSWRImmutable
でも useSWR
でも同じです。
ということでこのボタンを押下すれば、Headerコンポーネントの画像が切り替わることを確認することができます。めちゃくちゃ便利ですね。
App Routerの場合
Page Routerの実装サンプルで、SWRのキャッシュは、キーさえ一致すれば任意のタイミングで更新できることを説明しました。
例えばRecoilのように一意のキー名をAPIのエンドポイントに設定してどこかしらで管理さえしていれば、任意のタイミングでピンポイントに、かつ一部のデータのみキャッシュの更新が可能ということです。
App Routerの場合、revalidatePathとrevalidateTag があり、SWRのキャッシュとしては後者の revalidateTag
が代替手段になりそうです。
たとえばPage Routerでヘッダーに表示する画像の取得API(GET: /api/user/avatar)にタグを付けるには以下のようにします。SWR→fetchに変えてます。
await fetch("/api/user/avatar", {
next: { tags: ["avatar"] },
});
あとは index.tsx
で mutate
を呼んでいた部分を変更するだけです。
const handleChangeAvatar = async () => {
console.log('change avatar');
const response = await fetch('/api/user/avatar', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ avatarUrl: '/globe.svg' }),
});
if(!response.ok) {
console.error('Failed to update avatar');
return;
}
//const updatedData = await response.json();
- mutate('/api/user/avatar');
+ revalidateTag('avatar');
}
めちゃくちゃシンプルです。フロントエンド初心者にはかなり理解しやすいですね。
参考:
ただ、キーを適当に量産したりすると意図せず更新したりされなかったり、同じドメインや要素なのに片方は更新されるが片方はされないなどの不都合が起きそうです。ここはルールで縛る、独自のLinterを作るなどして整合性を保つ必要がありそう。
まとめ
- App RouterではSWRいらなくなりそう
- revalidatePathもrevalidateTagも便利だが使い方にある程度ルールを作っておかないと複雑化しそう
Next.jsもReactも、知らない機能がまだまだありそうなので日々勉強です。