【Next.js】revalidatePathを呼んだのに画面が更新されなかった話と対処法

JavaScript

こんにちは、かつコーチです。

前回はRoute Handlersを使ったAPIエンドポイントの作り方と、Server Actionsとの使い分けを解説しました。

Server Actionsでデータを更新した後にキャッシュを再検証するrevalidatePathは基本の機能ですが、私は実際にこれを呼んでも画面が更新されないという現象にハマったことがあります。

今回はその原因と対処法を、実体験ベースで詳しく解説します。

revalidatePathとは?何が起きるか

キャッシュの再検証を指示する関数

revalidatePathは、指定したパスに紐づくNext.jsのキャッシュを無効化し、次回アクセス時に再取得させるための関数です。

Server ActionsやRoute Handlersの中で、データ更新が成功した直後に呼び出すのが典型的な使い方です。

// app/actions.ts
'use server';

import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";

export async function updateTitle(id: number, title: string) {
  await db.post.update({ where: { id }, data: { title } });
  revalidatePath(`/posts/${id}`);
}

このコードだけを見ると、更新後にキャッシュがクリアされて最新の状態が画面に反映されそうに思えます。

なぜ「効かない」と感じることがあるのか

revalidatePathは、あくまでサーバー側のキャッシュを無効化する関数です。

ところがブラウザ側には、Next.jsのRouter自体が持つクライアントサイドのキャッシュ(Router Cache)が別に存在します。

サーバー側のキャッシュが更新されても、クライアント側がまだ古いキャッシュを参照していると、画面には反映されないことがあります。

これが「revalidatePathを呼んだのに画面が変わらない」と感じる現象の正体です。

基本の書き方

手順1:revalidatePathの基本的な使い方

まずは正しい基本形をおさらいします。

// app/actions.ts
'use server';

import { revalidatePath } from "next/cache";

export async function createPost(formData: FormData) {
  const title = formData.get("title") as string;

  await db.post.create({ data: { title } });

  revalidatePath("/posts"); // 一覧ページのキャッシュを再検証
}

一覧ページ(/posts)のキャッシュを狙って再検証する、シンプルな使い方です。

手順2:複数ページを再検証する

詳細ページと一覧ページの両方を最新化したい場合は、revalidatePathを複数回呼びます。

'use server';

import { revalidatePath } from "next/cache";

export async function updatePost(id: number, title: string) {
  await db.post.update({ where: { id }, data: { title } });

  revalidatePath("/posts");        // 一覧ページ
  revalidatePath(`/posts/${id}`);  // 詳細ページ
}

更新の影響範囲を洗い出し、関係するパスをすべて指定するのが基本です。

手順3:router.refresh()を併用する

クライアント側からServer Actionを呼んだ直後に、router.refresh()を組み合わせるとより確実です。

// app/posts/[id]/edit-form.tsx
'use client';

import { useRouter } from "next/navigation";
import { updatePost } from "@/app/actions";

export function EditForm({ id }: { id: number }) {
  const router = useRouter();

  async function handleSubmit(formData: FormData) {
    await updatePost(id, formData.get("title") as string);
    router.refresh(); // クライアント側のRouter Cacheも破棄する
  }

  return (
    <form action={handleSubmit}>
      <input name="title" />
      <button type="submit">更新</button>
    </form>
  );
}

router.refresh()は、現在のルートのRouter Cacheを破棄し、サーバーから最新のデータを再取得させるNext.jsのAPIです。

revalidatePath(サーバー側)とrouter.refresh()(クライアント側)は役割が異なるため、両方を意識する必要があります。

つまずきポイント・一次情報の体験談

実際に起きたこと

私が管理画面のタイトル編集機能を作っていたときのことです。

Server Action内でrevalidatePathを呼び、モーダルを閉じて一覧ページに戻る実装にしていました。

❌ Before:revalidatePathだけに頼った実装

// app/posts/edit-modal.tsx
'use client';

import { updatePost } from "@/app/actions";

export function EditModal({ id, onClose }: { id: number; onClose: () => void }) {
  async function handleSubmit(formData: FormData) {
    await updatePost(id, formData.get("title") as string);
    onClose(); // モーダルを閉じるだけ
  }

  return (
    <form action={handleSubmit}>
      <input name="title" />
      <button type="submit">保存</button>
    </form>
  );
}

Server Action側のupdatePostにはrevalidatePath("/posts")をきちんと入れていたのですが、モーダルを閉じて一覧に戻っても、編集前のタイトルが表示されたままでした。

ブラウザをリロードすると正しいタイトルに変わるので、「サーバー側のデータは更新されているのにクライアントの表示だけが古い」という状態でした。

原因の調査

最初はDBの更新処理そのものを疑い、トランザクションの実装を何度も見直しましたが問題はありませんでした。

Next.jsのドキュメントを読み返し、Router Cacheという「クライアント側にも別にキャッシュがある」という仕組みの存在にそのとき初めて気づきました。

revalidatePathはNext.jsサーバー側のキャッシュ(Full Route Cache・Data Cache)を無効化するものであり、ブラウザ側のRouter Cacheまでは自動的にクリアしてくれないケースがあったのです。

✅ After:router.refresh()を併用する

'use client';

import { useRouter } from "next/navigation";
import { updatePost } from "@/app/actions";

export function EditModal({ id, onClose }: { id: number; onClose: () => void }) {
  const router = useRouter();

  async function handleSubmit(formData: FormData) {
    await updatePost(id, formData.get("title") as string);
    router.refresh(); // ここを追加
    onClose();
  }

  return (
    <form action={handleSubmit}>
      <input name="title" />
      <button type="submit">保存</button>
    </form>
  );
}

onClose()の前にrouter.refresh()を追加したところ、モーダルを閉じた瞬間に一覧が最新のタイトルで表示されるようになりました。

さらに安定させる:revalidateTagへの切り替え

複数のパスから同じデータを参照している場合、revalidatePathをパスの数だけ書き並べるのは漏れが出やすく保守性も落ちます。

そのようなケースでは、タグ単位でキャッシュを管理するrevalidateTagに切り替えるとより安定します。

// データ取得側でタグを付ける
async function getPosts() {
  const res = await fetch("https://api.example.com/posts", {
    next: { tags: ["posts"] },
  });
  return res.json();
}
// 更新側でタグ単位で再検証する
'use server';

import { revalidateTag } from "next/cache";

export async function updatePost(id: number, title: string) {
  await db.post.update({ where: { id }, data: { title } });
  revalidateTag("posts"); // "posts"タグが付いたキャッシュをすべて無効化
}

パスを個別に指定する必要がなくなり、「このデータに紐づくキャッシュはすべてpostsタグで管理する」という発想に統一できます。

私のチームでは、この一件以降「Server Actionでデータを更新したら、revalidatePath(またはrevalidateTag)とセットでrouter.refresh()も検討する」というルールを設けました。

まとめ

この記事のポイント

  • revalidatePathはサーバー側のキャッシュを再検証するもので、クライアント側のRouter Cacheまでは必ずしも自動でクリアされない
  • 画面が更新されない場合は、Server Action呼び出し後にrouter.refresh()を併用すると安定する
  • 複数パスに同じデータが影響する場合は、revalidatePathの羅列よりrevalidateTagによるタグ単位の管理のほうが保守しやすい
  • 「サーバー側の再検証」と「クライアント側のキャッシュ破棄」は別の仕組みだと意識しておくと、この手のバグを防ぎやすい

次に読むべき記事

キャッシュの次によくハマるのが、環境変数の扱い方です。

次回は、環境変数の扱い方について、NEXTPUBLICの付け忘れでハマった話を紹介します。

→ 次の記事:環境変数の扱い方:NEXTPUBLICの付け忘れでハマった話

タイトルとURLをコピーしました