Next.js 15×React 19でキャッシュ地獄、本番6ヶ月で学んだ教訓

useEffect廃止、Server Components、自動キャッシング。本番環境で実際に困ったデータ更新の課題と、実装パターンの落とし穴を3回の本番対応から解き明かします。

Next.js 15 × React 19、本番6ヶ月で地獄を見た話

先日のチーム会議で「あの時のキャッシュ設計、何が原因だったのか、まだ納得いってないんですよね」って話が出た。実は俺も。Next.js 15とReact 19を本番投入してから、データが古いまま表示されたり、逆に更新が反映されなかったり、地味に本番対応を3回くらいやってる。正直なところ、ネットの記事だけでは足りなかった。

去年はApp Routerが「来るぞ、来るぞ」って触れ回られてて、今年はReact 19のServer Components、useEffect廃止、自動キャッシング戦略と、毎月ガラッと仕様が変わってる。うちのプロダクトで実装してみて気づいたこと、ようやく言語化できた。

useEffect廃止で気づいた、データフェッチのパラダイムシフト

React 19でuseEffectが「実質廃止」される—正確には使えるけど、推奨されなくなった。最初は「は?useEffectなしでどうするの」って感じだった。

従来のやり方:

// useEffect時代のコンポーネント
export function ProductList() {
  const [products, setProducts] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('/api/products')
      .then(res => res.json())
      .then(data => {
        setProducts(data);
        setLoading(false);
      });
  }, []);

  if (loading) return <p>Loading...</p>;
  return <div>{/* products表示 */}</div>;
}

これがReact 19 × Next.js 15だと:

// Server Component で直接データフェッチ
async function ProductList() {
  const products = await fetch(
    'http://localhost:3000/api/products',
    { next: { revalidate: 3600 } } // ここが重要
  ).then(res => res.json());

  return (
    <div>
      {products.map(p => <ProductCard key={p.id} product={p} />)}
    </div>
  );
}

最初「これめっちゃシンプルでいいじゃん」って思ってた。が、ここからが地獄の始まりだ。

キャッシュの「デフォルト値」が想定と違う件

Next.js 15から、fetchに渡すnextオプションのデフォルト挙動が変わった。以下の比較表を見てほしい:

パターンコード例キャッシュ期間リスク
revalidateなしfetch('/api/data')デプロイまで永続⚠️ 新規データが表示されない
revalidate指定fetch('/api/data', { next: { revalidate: 60 } })60秒ごとバランス型
キャッシュなしfetch('/api/data', { next: { revalidate: 0 } })なし毎回フェッチ、サーバー負荷↑
タグベースfetch('/api/data', { next: { tags: ['products'] } })手動リセット柔軟で管理複雑

ここで我々が踏んだ地雷:デフォルトではキャッシュが「永続的」に効く。つまり、新しくデータが追加されても、デプロイするまで古いデータが返ってくる。

うちのアプリでは、ユーザーが新しい商品を追加したのに、5時間表示されないという事象が発生した。原因はこれだったんだ。

// 最初のやらかしコード
async function CategoryPage() {
  const categories = await fetch(
    'http://localhost:3000/api/categories'
    // ← revalidate 指定なし=デプロイまで永続キャッシュ
  ).then(res => res.json());

  return <CategoryList categories={categories} />;
}

// 正解:ISR(Incremental Static Regeneration)を使う
async function CategoryPage() {
  const categories = await fetch(
    'http://localhost:3000/api/categories',
    { next: { revalidate: 300 } } // 5分ごとに再検証
  ).then(res => res.json());

  return <CategoryList categories={categories} />;
}

Server Components × Client Componentsの境界線が曖昧だった

リアクションのレベルアップで「Server Components使え」ってネットに書いてある。うちのチームも最初、全部Server Componentsで書いてた。でも実装してみたら、ユーザーインタラクションが必要な部分は結局use clientが必要になる。その時点で、データフェッチの戦略が一気に複雑になるんだよね。

// Server Componentでデータ取得
async function ProductDetail({ productId }) {
  const product = await fetch(
    `http://localhost:3000/api/products/${productId}`,
    { next: { revalidate: 60 } }
  ).then(res => res.json());

  return (
    <div>
      <ProductInfo product={product} />
      {/* 関数コンポーネント内でClient Componentを使う */}
      <ProductActions product={product} />
    </div>
  );
}

// Client Component(インタラクティブな部分)
'use client';

function ProductActions({ product }) {
  const [favorites, setFavorites] = useState([]);

  const handleAddToFavorite = async () => {
    // ← ここでServer Componentのデータをどう扱う?
    // Client側で再フェッチ?でもServer Componentでキャッシュされてる…
    const res = await fetch(`/api/favorites/${product.id}`, {
      method: 'POST',
      next: { revalidate: 0 } // ← これ、Client ComponentではServer側のキャッシュ無視される
    });
    setFavorites([...favorites, product.id]);
  };

  return <button onClick={handleAddToFavorite}>Add</button>;
}

気づいたのは、Server ComponentでServer側のキャッシュされたデータを取得して、Client Componentでそのデータを更新する時、同期が取れなくなること。Server Componentが返したデータは既にレンダリング時点で「確定」してて、Client側で更新してもServer側のキャッシュには反映されない。これは地味にハマりやすい。

対策として、うちのチームはrevalidateTagを使うようにした:

// Server Action で再検証を明示的にトリガー
'use server';

import { revalidateTag } from 'next/cache';

export async function addToFavorite(productId) {
  await fetch(`http://localhost:3000/api/favorites`, {
    method: 'POST',
    body: JSON.stringify({ productId }),
    headers: { 'Content-Type': 'application/json' }
  });
  
  // Server Componentのキャッシュを明示的に無効化
  revalidateTag('products');
}

// Server Component
async function ProductDetail({ productId }) {
  const product = await fetch(
    `http://localhost:3000/api/products/${productId}`,
    { next: { tags: ['products'] } } // ← タグをつける
  ).then(res => res.json());

  return <ProductInfo product={product} />;
}

// Client Component
'use client';

function ProductActions({ product }) {
  const handleAddToFavorite = async () => {
    await addToFavorite(product.id); // Server Action呼び出し
    // ← これでServer Componentのキャッシュがリセットされる
  };

  return <button onClick={handleAddToFavorite}>Add</button>;
}

データフェッチの3パターン、どれ選ぶ?

ぶっちゃけ、6ヶ月やって気づいたのは「キャッシュ戦略は実装の複雑さと効果のバランスを見極める必要がある」ってこと。各パターンの使い分けを説明する。

パターン1:キャッシュ頼み(シンプルだけどリスク大)

// 例:ブログの記事一覧
async function BlogList() {
  const posts = await fetch('http://localhost:3000/api/posts', {
    next: { revalidate: 86400 } // 24時間
  }).then(res => res.json());

  return <div>{/* posts */}</div>;
}

メリットはシンプルなことと、データベースへの問い合わせが減ること。デメリットは古いデータが長く表示されることで、24時間以内に記事を削除しても、ユーザーには見えたままになるかもしれない。

パターン2:タグベースキャッシュ(柔軟性重視)

async function ProductList() {
  const products = await fetch('http://localhost:3000/api/products', {
    next: { tags: ['products'] }
  }).then(res => res.json());

  return <div>{/* products */}</div>;
}

// 管理画面で商品を追加した時
'use server';
export async function createProduct(data) {
  await db.products.create(data);
  revalidateTag('products'); // このタグのキャッシュが全部リセット
}

メリットはグラニュラーな制御ができることと、データ更新と同時に再検証できることだ。デメリットはServer Action側とのコーディネーションが必要で、複雑になりやすい。

パターン3:Dynamic Rendering(毎回フェッチ)

// リアルタイムデータが必要な場合
async function LiveStats() {
  const stats = await fetch('http://localhost:3000/api/stats', {
    cache: 'no-store' // キャッシュしない
  }).then(res => res.json());

  return <div>{/* stats */}</div>;
}

メリットは常に最新データが手に入ること。デメリットは毎回フェッチするのでサーバー負荷が高くなり、ページロードが遅くなる。

正直、うちのチームが落ち着いた戦略は「階層化」だった。データの性質に応じて、キャッシュ戦略を分ける:

// レイヤー1:ほぼ変わらないマスターデータ(カテゴリ、ブランド等)
// → 24〜48時間のISR
const categories = await fetch('/api/categories', {
  next: { revalidate: 86400 }
});

// レイヤー2:頻繁に更新されるが、秒単位の更新は不要(商品一覧)
// → 5〜10分のISR + タグベースキャッシュ
const products = await fetch('/api/products', {
  next: { revalidate: 300, tags: ['products'] }
});

// レイヤー3:リアルタイムデータ(在庫、レーティング)
// → キャッシュなし、または useEffect で別途フェッチ
const inventory = await fetch('/api/inventory', {
  cache: 'no-store'
});

メモリ枯渇で本番が止まった話

これが地味に痛かった。キャッシュ戦略を甘く見てると、メモリがどんどん膨れ上がるんだ。

原因はISRでrevalidate: 60(1分ごと)にしてたページが、大量のアクセスを受けると、毎分新しいHTMLが生成されてメモリに積まれること。うちのアプリでは、動的ルート(例:/products/[id])でこれが起きた:

// 危ない:毎分、全ての[id]バリエーションが再検証される
export const revalidate = 60;

async function ProductPage({ params }) {
  const product = await fetch(
    `http://localhost:3000/api/products/${params.id}`,
    { next: { revalidate: 60 } }
  ).then(res => res.json());

  return <div>{/* product */}</div>;
}

1000個の商品があって、アクセスが均等に分散されてれば理論的には問題ないはず。でも実際には「新着商品」ページとか「セール商品」ページがあって、そこに載ってる商品ばかり頻繁にアクセスされる。つまり、数十個の商品ページだけが毎分再検証されて、メモリに残る。

メモリ監視して気づいた時点で、Dockerfile内のNode.jsプロセスが900MBまで膨れ上がってた。本来は200MB程度のはずなんだけどね。

対策は3つある:

// 方法1:revalidateの値を長くする
export const revalidate = 3600; // 1時間ごと

// 方法2:ジェネレータで事前ビルド時に必要な商品だけを生成
export async function generateStaticParams() {
  const products = await fetch('http://localhost:3000/api/products')
    .then(res => res.json());

  return products
    .filter(p => p.isFeatured) // 注目商品だけ
    .map(p => ({
      id: String(p.id)
    }));
}

// 方法3:キャッシュストレージを外部化(Redis等)
// ← Next.js 15.1以降で対応予定

結局、方法2(generateStaticParams)に落ち着いた。注目商品だけを事前ビルドして、それ以外はISRで柔軟に対応する、という流れになった。

React 19 の useフック、思ってたのと違う

useフックが追加されて「Promise を直接コンポーネントで使える」ってなってた。便利そうだなって思って使ってみたら、思想が全然違かった。

// Promiseをコンポーネントで受け取る
function ProductCard({ productPromise }) {
  const product = use(productPromise);
  return <div>{product.name}</div>;
}

// 親で Promise を作って渡す
'use client';

function ProductList() {
  const [productId, setProductId] = useState(1);
  
  // ← この Promise が毎回新しく作られるので、子コンポーネントが毎回Suspendする
  const productPromise = fetch(`/api/products/${productId}`)
    .then(res => res.json());

  return (
    <>
      <button onClick={() => setProductId(productId + 1)}>Next</button>
      <Suspense fallback={<p>Loading...</p>}>
        <ProductCard productPromise={productPromise} />
      </Suspense>
    </>
  );
}

見た目はシンプルだけど、useフックはClient Component内で使われるべきものじゃなくて、Server ComponentからClient Componentに「途中のPromise」を渡すための仕組みだったんだ。結局、複雑さが増してしまう。

うちのチームは、結局useフックはあまり使わず、従来のuseState + useEffectuseSWR的なライブラリに頼ることになった。useフックは万能じゃなかったってのが、正直な感想だな。

2026年時点での落ち着きどころ

半年やってて、「これが正解」ってパターンが見えてきた。以下が、うちのチームが採用した標準的な構成だ:

// フォルダ構成
// app/
//   ├ (common)/              ← 共通レイアウト
//   ├ products/
//   │  └ [id]/
//   │     ├ layout.tsx       ← Server Component:キャッシュデータ
//   │     └ page.tsx         ← Server Component:ISRで取得
//   ├ api/                   ← API Route
//   └ actions.ts             ← Server Actions

// Server Component:マスターデータ + ISR
export const revalidate = 3600; // 1時間

async function ProductLayout({ children, params }) {
  const product = await fetch(
    `http://localhost:3000/api/products/${params.id}`,
    { next: { tags: ['product'], revalidate: 3600 } }
  ).then(res => res.json());

  return (
    <div>
      <h1>{product.name}</h1>
      {children}
    </div>
  );
}

// Server Action:ユーザーアクションのトリガー
'use server';
export async function addToCart(productId) {
  const cart = await getCartFromSession();
  cart.push(productId);
  await saveCartToSession(cart);
  revalidateTag('cart'); // キャッシュリセット
}

// Client Component:リアルタイムインタラクション
'use client';
export function AddToCartButton({ productId }) {
  const [isLoading, setIsLoading] = useState(false);

  const handleClick = async () => {
    setIsLoading(true);
    await addToCart(productId);
    setIsLoading(false);
    toast.success('Added to cart!');
  };

  return <button onClick={handleClick}>Add</button>;
}

キャッシュの流れを図にするとこんな感じだ:

graph TD
  A["User Request"] --> B{"Server Component<br/>でキャッシュ確認"}
  B -->|"Cache Hit" | C["キャッシュから返す"]
  B -->|"Cache Miss" | D["DB + API Fetch"]
  D --> E["HTML生成 + キャッシュ保存"]
  C --> F["Hydration"]
  E --> F
  F --> G{"ユーザー<br/>インタラクション"}
  G -->|"Client イベント" | H["Client Component"]
  H --> I["Server Action 実行"]
  I --> J{"キャッシュ<br/>再検証が必要?"}
  J -->|"Yes" | K["revalidateTag 実行"]
  K --> L["Server Component<br/>再レンダリング"]
  J -->|"No" | M["データベース更新のみ"]
  L --> N["クライアント更新"]
  M --> N

まとめ

Next.js 15 × React 19のキャッシュ設計は、ネットの情報だけでは足りない。実装して初めて分かることが多い。

1. デフォルトキャッシュは永続的

revalidateを明示的に指定しないと、デプロイまでキャッシュが生きる。アプリの仕様によっては「古いデータが表示される」という致命的なバグになる。

2. Server Component × Client Component の境界が曖昧

両方を組み合わせる時、キャッシュ戦略が複雑になる。Server Actions + revalidateTag で制御するのが現実的だったな。

3. メモリ管理が必須

ISRで毎分再検証すると、メモリが膨れ上がる。generateStaticParamsで事前ビルド対象を絞るか、revalidateの値を長くするか、戦略を立てる必要がある。

4. 階層化キャッシュが実用的

マスターデータ(長いISR)、更新データ(短いISR + タグ)、リアルタイムデータ(キャッシュなし)で分けると、保守しやすくなる。

5. useEffect廃止は本当

Server ComponentsとServer Actionsが標準になった今、クライアント側のデータフェッチはなるべく減らす方が吉だ。

正直、2026年時点でもまだ試行錯誤してる部分は多い。ただ、6ヶ月本番で運用したからこそ見えたパターンがある。うちのチームも、新入りのエンジニアには「キャッシュ設計は最初から完璧を目指さず、運用しながら調整する」って伝えるようにした。

実装する前に、この記事の図を参考にして、自分たちのアプリに合ったキャッシュ戦略を設計してみてほしい。完璧さより、いかに早く本番で学ぶか。その方が結果的に、堅牢なアーキテクチャになるんだと思うな。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事