📑

【Next.js】router.refresh() が何をしているのか理解する

2024/06/25に公開

公式によると...

https://arc.net/l/quote/jxtudbaj

router.refresh(): 現在のルートを更新します。サーバーに新しいリクエストを行い、データリクエストを再取得し、Server Component を再レンダリングします。クライアントは、更新された React Server Component のペイロードを、影響を受けないクライアント側の React(useState など)やブラウザの状態(スクロール位置など)を保持したままマージします。

上記内容を箇条書きでまとめると以下の通りになります。

  • 現在のルートを更新する
  • サーバーに新しいリクエストを行う
  • Server Component を再レンダリングする
  • クライアントは、サーバーから受け取った新たな RSC ペイロードを、クライアント側の React(useState など)やブラウザの状態(スクロール位置など)を保持したままマージする

どういうこと?

箇条書きでまとめた項目を一つずつ見ていきます。

現在のルートを更新する

router.refresh() を実行した Root Segment(ページ)を更新するという意味です。
しかし後述によると、「更新」と言ってもページ全体を 0 から再構築しているわけではいようです。つまり、単純にページをリロードしている(window.location.reload() を実行している)わけではないということです。
それではこの「更新」とは一体何をしているのでしょうか?

サーバーに新しいリクエストを行う

「新たにサーバーリクエストする」という観点だと window.location.reload() と同じですが、router.refresh() は何をリクエストしているのでしょうか?

Server Component を再レンダリングする

どうやら Server Component を再レンダリングし、RSC ペイロードを生成しているようです。
「Server Component を再レンダリングする」とありますが、これはサーバー側で再度 RSC ペイロードを生成し直しているだけです。

クライアントは、サーバーから受け取った新たな RSC ペイロードを、クライアント側の React(useState など)やブラウザの状態(スクロール位置など)を保持したままマージする

こちらはどういうことでしょうか?
再生成した RSC を用いてクライアント側で再描画することは分かります。しかし、「クライアント側の React やブラウザの状態を保持したままマージする」というのは本当なのでしょうか?
こちらを確認するために、ToDo アプリを作成して検証してみます。

ToDo アプリを実装して router.refresh() の理解を深める

以下のような Client Component(CC)と Server Component(SC)が混在する画面を実装します。

実装概要

  • Next.js サーバー:localhost:3000
  • API サーバ:localhost:3001
ルートコンポーネント
page.tsx
import { Suspense } from "react";
import { AddTaskForm } from "@/components/AddTaskForm";
import { TaskList } from "@/components/TaskList";
import Link from "next/link";

export default function Home() {
  return (
    <div className="flex flex-col h-screen bg-gray-100 dark:bg-gray-900">
      <header className="bg-white dark:bg-gray-800 shadow py-4 px-6">
        <h1 className="text-2xl font-bold text-gray-800 dark:text-gray-200">
          TODO App
        </h1>
        <Link href="/sample">Sample</Link>
      </header>
      <div className="flex-1 p-6 space-y-4">
        <AddTaskForm />
        <div className="bg-white dark:bg-gray-800 rounded-md shadow p-4 space-y-2">
          <Suspense fallback={<div>Loading...</div>}>
            <TaskList />
          </Suspense>
        </div>
      </div>
    </div>
  );
}
タスク追加フォーム(AddTaskForm)

<form> を使用していますが、今回は router.refresh() の検証を行いたいので、データ追加処理に Server Actions は利用しません。

AddTaskForm.tsx
"use client";

import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { Task } from "@/types/task";
import { v4 as uuidV4 } from "uuid";

export function AddTaskForm() {
  const [title, setTitle] = useState("");

  const handleSubmit = async () => {
    const body: Task = { id: uuidV4(), title, isCompleted: false };
    await fetch("http://localhost:3001/tasks", {
      method: "POST",
      body: JSON.stringify(body),
    });
  };

  return (
    <form className="flex items-center space-x-4" onSubmit={handleSubmit}>
      <Input
        type="text"
        placeholder="Add a new task..."
        className="flex-1 bg-white dark:bg-gray-800 dark:text-gray-200 rounded-md py-2 px-4 focus:outline-none focus:ring-2 focus:ring-blue-500"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
      />
      <Button
        type="submit"
        className="bg-blue-500 hover:bg-blue-600 text-white rounded-md py-2 px-4"
      >
        Add
      </Button>
    </form>
  );
}
タスク一覧(TaskList)

今回は router.refresh() の動作検証に集中したいので Data Cash と Full Route Cache はオプトアウトしています。

TaskList.tsx
import { Task } from "@/types/task";
import { TaskItem } from "./TaskItem";

const getTodos = async (): Promise<Task[]> => {
  const res = await fetch("http://localhost:3001/tasks", {
    cache: "no-store",
  });
  const data = await res.json();
  return data;
};

export async function TaskList() {
  const tasks = await getTodos();

  return (
    <div>
      {tasks.map((task) => (
        <TaskItem key={task.id} task={task} />
      ))}
    </div>
  );
}
タスクアイテム(TaskItem)
TaskItem.tsx
"use client";

import { useState } from "react";
import { Task } from "@/types/task";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { EditButton } from "@/components/EditButton";
import { DeleteButton } from "@/components/DeleteButton";
import { SaveButton } from "@/components/SaveButton";

type Props = {
  task: Task;
};

export function TaskItem({ task }: Props) {
  const [isEditingTitle, setIsEditingTitle] = useState(false);
  const [editedTitle, setEditedTitle] = useState(task.title);

  const handleDoneTask = async () => {
    const body: Task = { ...task, isCompleted: !task.isCompleted };
    fetch(`http://localhost:3001/tasks/${task.id}`, {
      method: "PUT",
      body: JSON.stringify(body),
    });
  };

  const handleEditButtonClock = () => {
    setIsEditingTitle(true);
  };

  const handleSaveTitle = async () => {
    setIsEditingTitle(false);
    if (task.title === editedTitle) return;
    const body: Task = { ...task, title: editedTitle };
    await fetch(`http://localhost:3001/tasks/${task.id}`, {
      method: "PUT",
      body: JSON.stringify(body),
    });
  };

  const handleDeleteTask = async () => {
    await fetch(`http://localhost:3001/tasks/${task.id}`, {
      method: "DELETE",
    });
  };

  return (
    <div className="flex items-center space-x-4">
      <Checkbox
        id={`task-${task.id}`}
        checked={task.isCompleted}
        onChange={handleDoneTask}
      />
      {isEditingTitle ? (
        <Input
          type="text"
          className="flex-1 bg-white dark:bg-gray-800 dark:text-gray-200 rounded-md py-2 px-4 focus:outline-none focus:ring-2 focus:ring-blue-500"
          value={editedTitle}
          onChange={(e) => setEditedTitle(e.target.value)}
        />
      ) : (
        <label
          htmlFor={`task-${task.id}`}
          className={`flex-1 text-gray-800 dark:text-gray-200 ${
            task.isCompleted ? "line-through" : ""
          }`}
        >
          {task.title}
        </label>
      )}
      {isEditingTitle ? (
        <SaveButton handleClick={handleSaveTitle} />
      ) : (
        <EditButton handleClick={handleEditButtonClock} />
      )}
      <DeleteButton handleClick={handleDeleteTask} />
    </div>
  );
}

タスクの追加・編集・削除操作を行い、router.refresh() がどのように動作するのかを確認します。

router.refresh() しないとどうなるのか?

上記の実装には router.refresh() を記述していません。
この場合どのような動作になるのか確認してみます。

タスクを追加する
AddTaskForm.tsx

// 省略

const [title, setTitle] = useState("");

const handleSubmit = async (e: FormEvent) => {
  e.preventDefault();
  const body: Task = { id: uuidV4(), title, isCompleted: false };
  await fetch("http://localhost:3001/tasks", {
    method: "POST",
    body: JSON.stringify(body),
  });
  setTitle("");
};


// 省略

追加したデータが画面に反映されていません。

タスクを完了/未完了にする
TaskItem.tsx
// 省略

const handleToggleDoneTask = async () => {
  const body: Task = { ...task, isCompleted: !task.isCompleted };
  await fetch(`http://localhost:3001/tasks/${task.id}`, {
    method: "PUT",
    body: JSON.stringify(body),
  });
};

// 省略

完了/未完了の更新処理が画面に反映されていません。

タスクのタイトルを更新する
TaskItem
// 省略

const [isEditingTitle, setIsEditingTitle] = useState(false);
const [editedTitle, setEditedTitle] = useState(task.title);

const handleEditButtonClick = () => {
  setIsEditingTitle(true);
};

const handleSaveTitle = async () => {
  setIsEditingTitle(false);
  if (task.title === editedTitle) return;
  const body: Task = { ...task, title: editedTitle };
  await fetch(`http://localhost:3001/tasks/${task.id}`, {
    method: "PUT",
    body: JSON.stringify(body),
  });
};

// 省略

タイトルの更新が画面に反映されていません。

タスクを削除する
TaskItem.tsx
// 省略

const handleDeleteTask = async () => {
  await fetch(`http://localhost:3001/tasks/${task.id}`, {
    method: "DELETE",
  });
};

// 省略

タスクの削除が画面に反映されていません。

データの追加・更新処理が成功し、DB にも正しく登録されていますが、画面には反映されていません。
その理由はミューテーションを行っていないからです。更新系 API をクライアント側でコールした後、更新後のデータを再取得する必要があります。
ただ、タスクのデータを取得し TaskItem コンポーネントに props 経由で渡している TaskList コンポーネントは SC です。

TaskList コンポーネントのコードを確認する
TaskList.tsx
import { Task } from "@/types/task";
import { TaskItem } from "./TaskItem";

const getTodos = async (): Promise<Task[]> => {
  const res = await fetch("http://localhost:3001/tasks", {
    cache: "no-store",
  });
  const data = await res.json();
  return data;
};

export async function TaskList() {
  const tasks = await getTodos();

  return (
    <div>
      {tasks.map((task) => (
        <TaskItem key={task.id} task={task} />
      ))}
    </div>
  );
}

よって、再度 TaskList コンポーネント上でデータを取得し、レンダリングをする必要があり、そのためには更新系 API をコールした後、新規の SC をリクエストしなければなりません。
それを可能にするのが router.refresh() です。

router.refresh() で SC を再レンダリングする

公式による説明は以下でした。

  • 現在のルートを更新する
  • サーバーに新しいリクエストを行う
  • Server Component を再レンダリングする
  • クライアントは、サーバーから受け取った新たな RSC ペイロードを、クライアント側の React(useState など)やブラウザの状態(スクロール位置など)を保持したままマージする

API をコールした後に router.refresh() を実行することで、更新後のデータが反映された SC をサーバーにリクエストしてくれます。リクエストを受け取ったサーバーは SC を再レンダリングし、RSC ペイロードを生成してクライアントにレスポンスします。

では、先ほどの API コール後に router.refresh() を追記します。

タスクを追加する
AddTaskForm.tsx
+ import { useRouter } from "next/navigation";

  // 省略

+ const router = useRouter();
  const [title, setTitle] = useState("");

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    const body: Task = { id: uuidV4(), title, isCompleted: false };
    await fetch("http://localhost:3001/tasks", {
      method: "POST",
      body: JSON.stringify(body),
    });
    setTitle("");
+   router.refresh();
  };

  // 省略

追加したデータが画面に反映されました!

タスクを完了/未完了にする
TaskItem.tsx
+ import { useRouter } from "next/navigation";

  // 省略

+ const router = useRouter();

  const handleToggleDoneTask = async () => {
    const body: Task = { ...task, isCompleted: !task.isCompleted };
    fetch(`http://localhost:3001/tasks/${task.id}`, {
      method: "PUT",
      body: JSON.stringify(body),
    });
+   router.refresh();
  };

  // 省略

完了/未完了の更新処理が画面に反映されました!

タスクのタイトルを更新する
TaskItem
+ import { useRouter } from "next/navigation";

  // 省略

+ const router = useRouter();
  const [isEditingTitle, setIsEditingTitle] = useState(false);
  const [editedTitle, setEditedTitle] = useState(task.title);

  const handleEditButtonClick = () => {
    setIsEditingTitle(true);
  };

  const handleSaveTitle = async () => {
    setIsEditingTitle(false);
    if (task.title === editedTitle) return;
    const body: Task = { ...task, title: editedTitle };
    await fetch(`http://localhost:3001/tasks/${task.id}`, {
      method: "PUT",
      body: JSON.stringify(body),
    });
+   router.refresh();
  };

  // 省略

タイトルの更新が画面に反映されました!

タスクを削除する
TaskItem.tsx
+ import { useRouter } from "next/navigation";

  // 省略

+ const router = useRouter();

  const handleDeleteTask = async () => {
    await fetch(`http://localhost:3001/tasks/${task.id}`, {
      method: "DELETE",
    });
+   router.refresh();
  };

  // 省略

タスクの削除が画面に反映されました!

router.refresh() により SC が再レンダリングし、更新後のデータを反映した RSC ペイロードがレスポンスされていることを確認できました。

クライアントの状態(useState)が保持されていることを確認する

クライアントは、更新された React Server Component のペイロードを、影響を受けないクライアント側の React(useState など)やブラウザの状態(スクロール位置など)を保持したままマージします。

こちらを検証します。

任意タスクのタイトルを編集中にした状態で、新たに別のタスクを作成します。

CC である TaskItem で定義した isEditingTitle の状態を true に変更しています。
その後、タスク追加の API コールを行い router.refresh() で SC をリクエストし、その結果を基にクライアント側で再描画しています。
再描画後の isEditingTitletrue のままです。ブラウザの状態が保持されたまま SC の変更がマージされています。

よって、router.refresh() は単に現在のルートをリロード(window.location.reload)しているわけではないことが確認できました。CC の状態を保ちつつ SC をリクエストし、その結果をマージしているのだと思います。

router.refresh() 実行中に発生するサスペンドの fallback が表示されない

タスク一覧取得に時間がかかる場合を考えます。

TaskList.tsx を変更
TaskList.tsx
  import { Task } from "@/types/task";
  import { TaskItem } from "./TaskItem";

  const getTodos = async (): Promise<Task[]> => {
    const res = await fetch("http://localhost:3001/tasks", {
      cache: "no-store",
      next: { tags: ["tasks"] },
    });
    const data = await res.json();

+   await new Promise((resolve) => {
+     setTimeout(() => {
+       resolve(null);
+     }, 3000);
+   });

    return data;
  };

  export async function TaskList() {
    const tasks = await getTodos();

    return (
      <div>
        {tasks.map((task) => (
          <TaskItem key={task.id} task={task} />
        ))}
      </div>
    );
  }

TaskList コンポーネントを <Suspence> で囲っているため、データ取得と SC のレンダリングが完了するまではローディング UI が表示されます。

この状態でタスクを追加してみます。

SC の再レンダリングに時間がかかるため、画面が更新されるまで時間がかかります。
この時、TasksList コンポーネントはサスペンド状態にあるため、Suspence で指定した fallback の表示に切り替わるはずです。
しかし、実際は TasksList が表示されたままになっています。これは何故なのでしょうか?

サスペンドしているのに fallback が表示されないのは何故か?

router.refresh() のソースコードを確認してみます。

https://github.com/vercel/next.js/blob/ea8020158e7f7f75242ac4dad03136b6a170b63c/packages/next/src/client/components/app-router.tsx#L404-L411

startTransition() が原因です。
startTransition() を使用すると、Suspence 内部がサスペンド状態でも fallback は表示されません。

https://ja.react.dev/reference/react/Suspense#preventing-already-revealed-content-from-hiding

https://zenn.dev/uhyo/books/react-concurrent-handson-2/viewer/use-starttransition#トランジションを使ってみる

fallback を表示させる方法

startTransition() を使わないようにすれば良いのですが、router.refresh() 内部に組み込まれているため、そうはいきません...
そこで、別の方法として Suspencekey を指定します。

page.tsx
  // 省略

  export default function Home() {
    return (
      <div className="flex flex-col h-screen bg-gray-100 dark:bg-gray-900">
        <header className="bg-white dark:bg-gray-800 shadow py-4 px-6">
          <h1 className="text-2xl font-bold text-gray-800 dark:text-gray-200">
            TODO App
          </h1>
          <Link href="/sample">Sample</Link>
        </header>
        <div className="flex-1 p-6 space-y-4">
          <AddTaskForm />
          <div className="bg-white dark:bg-gray-800 rounded-md shadow p-4 space-y-2">
-           <Suspense fallback={<div>Loading...</div>}>
+           <Suspense key={Math.random()} fallback={<div>Loading...</div>}>
              <TaskList />
            </Suspense>
          </div>
        </div>
      </div>
    );
  }

https://zenn.dev/frontendflat/articles/nextjs-suspense-use-transition#1.-suspense-%2B-rsc

これで、ページ更新の度に fallback が表示されるようになりました。

useOptimistic() で楽観的更新を行う

上記のような、ページ更新の度に fallback による待機が発生するのは良い UX とは言えません。
そこで、useOptimistic() を使用し、楽観的更新を行います。
https://ja.react.dev/reference/react/useOptimistic

useOptimistic() による楽観的更新は、startTransition または Server Actions 内で行う必要があります。

TaskItem.tsx
  "use client";

+ import { startTransition, useOptimistic, useState } from "react";

  type Props = {
    task: Task;
  };

  export function TaskItem({ task }: Props) {
    const router = useRouter();

    const [isEditingTitle, setIsEditingTitle] = useState(false);
    const [editedTitle, setEditedTitle] = useState(task.title);

+  const [optimisticTask, addOptimistic] = useOptimistic<Task | undefined>(task);

    const handleToggleDoneTask = async () => {
      const body: Task = { ...task, isCompleted: !task.isCompleted };
+     startTransition(async () => {
+       addOptimistic(body);
+       await fetch(`http://localhost:3001/tasks/${task.id}`, {
+         method: "PUT",
+         body: JSON.stringify(body),
+       });
+       router.refresh();
+     });
    };

    const handleEditButtonClick = () => {
      setIsEditingTitle(true);
    };

    const handleSaveTitle = async () => {
      setIsEditingTitle(false);
      if (task.title === editedTitle) return;
      const body: Task = { ...task, title: editedTitle };
+     startTransition(async () => {
+       addOptimistic(body);
+       await fetch(`http://localhost:3001/tasks/${task.id}`, {
+         method: "PUT",
+         body: JSON.stringify(body),
+       });
+       router.refresh();
+     });
    };

    const handleDeleteTask = async () => {
+     startTransition(async () => {
+       addOptimistic(undefined);
+       await fetch(`http://localhost:3001/tasks/${task.id}`, {
+         method: "DELETE",
+       });
+       router.refresh();
+     });
    };

+   if (!optimisticTask) return null;

    return (
      <div className="flex items-center space-x-4">
        <Checkbox
+         checked={optimisticTask.isCompleted}
          onCheckedChange={handleToggleDoneTask}
        />
        {isEditingTitle ? (
          <Input
            type="text"
            className="flex-1 bg-white dark:bg-gray-800 dark:text-gray-200 rounded-md py-2 px-4 focus:outline-none focus:ring-2 focus:ring-blue-500"
            value={editedTitle}
            onChange={(e) => setEditedTitle(e.target.value)}
          />
        ) : (
          <label
            className={`flex-1 text-gray-800 dark:text-gray-200 ${
+             optimisticTask.isCompleted ? "line-through" : ""
            }`}
          >
+           {optimisticTask.title}
          </label>
        )}
        {isEditingTitle ? (
          <SaveButton handleClick={handleSaveTitle} />
        ) : (
          <EditButton handleClick={handleEditButtonClick} />
        )}
        <DeleteButton handleClick={handleDeleteTask} />
      </div>
    );
  }
page.tsx
  // 省略

  export default function Home() {
    return (
      <div className="flex flex-col h-screen bg-gray-100 dark:bg-gray-900">
        <header className="bg-white dark:bg-gray-800 shadow py-4 px-6">
          <h1 className="text-2xl font-bold text-gray-800 dark:text-gray-200">
            TODO App
          </h1>
          <Link href="/sample">Sample</Link>
        </header>
        <div className="flex-1 p-6 space-y-4">
          <AddTaskForm />
          <div className="bg-white dark:bg-gray-800 rounded-md shadow p-4 space-y-2">
+           <Suspense fallback={<div>Loading...</div>}>
-           <Suspense key={Math.random()} fallback={<div>Loading...</div>}>
              <TaskList />
            </Suspense>
          </div>
        </div>
      </div>
    );
  }

楽観的更新 →  更新 API コール →  router.refresh() の流れで処理されています。

router.refresh() は Router Cache をパージする

クライアント側でキャッシュされるRouter Cache内のすべてのルートがパージされます。

https://ja.next-community-docs.dev/docs/app-router/building-your-application/caching/#キャッシュを無効にする-1

Server Actions を使う場合は revalidatePath(), revalidateTag() を使う

クライアント側で更新系 API をコールする場合は router.refresh() を使用すれば良いですが、Server Actions でデータ更新を行う場合は使用できません。何故なら、Server Actions の関数はサーバー側で実行されるためです。router.refresh() はクライアント側でのみ実行可能です。

revalidatePath() でタスク追加処理を実装する
actions/taskActions.tsx
+ "use server";
+
+ import { Task } from "@/types/task";
+ import { revalidatePath } from "next/cache";
+
+ export const addTask = async (body: Task) => {
+   "use server";
+
+   await fetch("http://localhost:3001/tasks", {
+     method: "POST",
+     body: JSON.stringify(body),
+   });
+
+   revalidatePath("/");
+ };
TaskItem.tsx
  "use client";

  // 省略
- import { useRouter } from "next/navigation";
+ import { addTask } from "@/actions/taskActions";

  // 省略
- const router = useRouter();
const [title, setTitle] = useState("");

- const handleSubmit = async (e: FormEvent) => {
-   e.preventDefault();
-   const body: Task = { id: uuidV4(), title, isCompleted: false };
-   await fetch("http://localhost:3001/tasks", {
-     method: "POST",
-     body: JSON.stringify(body),
-   });
-   setTitle("");
-   router.refresh();
- };

+ const handleSubmit = async () => {
+   const body: Task = { id: uuidV4(), title, isCompleted: false };
+   addTask(body);
+   setTitle("");
+ };

  return (
-   <form className="flex items-center space-x-4" onSubmit={handleSubmit}>
+   <form className="flex items-center space-x-4" action={handleSubmit}>
      // 省略
    </form>
  );
}


revalidateTag() でタスク追加処理を実装する
TaskList.tsx
  // 省略

  const getTodos = async (): Promise<Task[]> => {
    const res = await fetch("http://localhost:3001/tasks", {
      cache: "no-store",
+     next: { tags: ["tasks"] },
    });
    const data = await res.json();
    return data;
  };

  // 省略
actions/taskActions.tsx
+ "use server";
+
+ import { Task } from "@/types/task";
+ import { revalidateTag } from "next/cache";
+
+ export const addTask = async (body: Task) => {
+   "use server";
+
+   await fetch("http://localhost:3001/tasks", {
+     method: "POST",
+     body: JSON.stringify(body),
+   });
+
+   revalidateTag("tasks");
+ };
TaskItem.tsx
  "use client";

  // 省略
- import { useRouter } from "next/navigation";
+ import { addTask } from "@/actions/taskActions";

  // 省略
- const router = useRouter();
const [title, setTitle] = useState("");

- const handleSubmit = async (e: FormEvent) => {
-   e.preventDefault();
-   const body: Task = { id: uuidV4(), title, isCompleted: false };
-   await fetch("http://localhost:3001/tasks", {
-     method: "POST",
-     body: JSON.stringify(body),
-   });
-   setTitle("");
-   router.refresh();
- };

+ const handleSubmit = async () => {
+   const body: Task = { id: uuidV4(), title, isCompleted: false };
+   addTask(body);
+   setTitle("");
+ };

  return (
-   <form className="flex items-center space-x-4" onSubmit={handleSubmit}>
+   <form className="flex items-center space-x-4" action={handleSubmit}>
      // 省略
    </form>
  );
}


revalidatePath()revalidateTag()router.refresh() と同様、Router Cache をパージします。

参考リンク

https://ja.next-community-docs.dev/docs/app-router/api-reference/functions/use-router

https://github.com/vercel/next.js/discussions/54075

https://www.reddit.com/r/nextjs/comments/1bsf1js/how_is_revalidatepath_and_routerrefresh_different/

https://github.com/vercel/next.js/discussions/58520

https://zenn.dev/uhyo/books/react-19-new/viewer/use-optimistic

GitHubで編集を提案

Discussion