Open2

React Router v7 でのクライアント側キャッシュ

yuheitomiyuheitomi

Next.js における "use cache" や unstable_cache などに相当する機能を React Router v7 (Framework) でどう実現するかを ChatGPT o3 相手に相談する中で、個人的に有用だと感じたクライアント側でのキャッシュの持たせ方について。

※ チャットのやり取りをベースに o3 にまとめさせたものです。

yuheitomiyuheitomi

React Router v7 クライアント高速化メモ

概要

clientLoaderメモリキャッシュ (Map/LRU) を組み合わせることで、同タブ内ページ遷移を爆速化するテクニックをまとめます。Next.js 15 の use cache() に近い体験を Remix でも簡単に実現できます。

1. なぜ clientLoader + メモリ?

利点 説明
SSR を邪魔しない 初回アクセスは従来どおり loader が実行されるため SEO を損なわない
データの再利用 同じルートへ戻ったときに即描画。無駄なネットワークを削減
実装がシンプル 依存パッケージ不要。Map 一つで完結
Edge/CDNと併用可 HTTP キャッシュと競合しないため、段階的パフォーマンス最適化に向く

2. アーキテクチャ

┌── loader() ──┐  (SSR)
│ fetch/API     │
└────┬──────────┘
     ▼ hydrate
┌─ clientLoader() ─────────────────────────────┐
│ 1. key = pathname                            │
│ 2. if in cache → return                      │
│ 3. else serverLoader() → cache.set(key,val)  │
└──────────────────────────────────────────────┘
  • ブラウザ側の 同タブ SPA セッション がキャッシュのスコープ。
  • HMR/リロードでキャッシュは消える。

3. 最小実装例

// routes/posts.tsx
import type { Post } from "~/types";

export async function loader({ params }) {
  const res = await fetch(`https://.../posts/${params.id}`);
  return { post: await res.json<Post>() };
}

// ① シングルトンメモリ
const cache = new Map<string, Post>();

export async function clientLoader({ request, serverLoader }) {
  const key = new URL(request.url).pathname;
  if (cache.has(key)) return { post: cache.get(key)! };

  const data = await serverLoader();     // SSR 値
  cache.set(key, data.post);
  return { post: data.post };
}
clientLoader.hydrate = true;             // ←必須

4. キャッシュキー設計指針

  • パス名 + クエリ文字列 を含めると A/B テストやフィルタ UI に対応可
  • セッション漏洩を防ぐため、ユーザー固有情報 (token 等) はキーに含めない
const url = new URL(request.url);
const key = `${url.pathname}?${url.search}`; // パラメータも区別

5. 無効化・再検証

方法 向き/留意点
TTL 付き Map set(key,val); setTimeout(()=>cache.delete(key), 60_000) 簡易だが大量キーで管理が煩雑
clientAction 後に delete CRUD 完了後 cache.delete(key) 即時 UI 反映に最適
shouldRevalidate() 重要ページのみ return false SPA 書き換え時は true を返す
フルリロード F5/⟳ 全キャッシュ破棄。バグ調査の最後手段

6. ルート横断でキャッシュ共有

  1. app/lib/client-cache.ts に上記 globalCache を定義
  2. すべての clientLoaderimport { cache } from "~/lib/client-cache";
  3. キー衝突を避けるため routeId:pathname など名前空間を付与

7. 失敗時ハンドリング

if (!post) throw json("Not Found", { status: 404 });
  • ErrorBoundary で復帰リンクを表示すると UX が崩れにくい

8. 適用シナリオと非推奨ケース

適している 理由
読み取り主体の一覧/詳細ページ 更新頻度が低くキャッシュヒット率が高い
パラメータ違いで同じリソースを参照 /posts/1, /posts/2 など隣接遷移が多い
オフライン時もできるだけ表示したい Service Worker と組み合わせ可
適さない 理由
ユーザー毎に頻繁に変わるデータ キャッシュ内容が古くなるリスク
セキュリティ上 URL が共用できない場合 キーの共用が危険 (例: 機密クエリ)

9. ベストプラクティスまとめ

  1. キーは URL 完全一致 + ユーザー識別が不要か確認
  2. hydrate = true を忘れずに
  3. 更新系アクション後は 積極的に cache.delete()
  4. 開発中は globalThis or DevTools でキャッシュ確認
  5. さらに早くしたい場合は HTTP Cache-Control か React Query を併用

10. 参考リンク

  • Remix Docs: Client Loaders & Client Actions
  • Kent C. Dodds — Caching Strategies in Remix
  • React Router v7 Data API RFC