AppRouter で SearchParams を変更したときに Loading が走らなくて困った話

2023/12/26に公開

株式会社CoeFontでプロダクト開発をしている uzimaru です。
AppRouter で SearchParams の変更をしたときに Loading が走らないという状況に遭遇したので記事にしようと思います。

また、今回の事例のサンプルのリポジトリは ↓ になっています
https://github.com/uzimaru0000/example-change-qs-for-approuter

変更後については こちら の PR を参照してみてください

実際に動く環境は

にデプロイされています。

背景

検索画面のようなページで検索条件を SearchParams に反映させ URL としてページを表現することはよくある実装だと思います。
その際、条件の変更に応じて検索画面にはローディング中の表示をしたくなります。

困ったポイント

AppRouter では loading.tsx というファイル作ることで Suspense の fallback としてそこに定義されているコンポーネントを利用してくれます。
loading.tsx を作って RSC で fetch などの非同期な処理を走らせると確かに loading.tsx のコンポーネントが非同期の解決まで表示されます。
しかし、同一 path で SearchParams を変更(Link コンポーネントでの遷移や next/navigateuseRouter を使った遷移)すると loading.tsx の内容は表示されません!
この状態だとユーザーが何らかの入力をしたあとに画面が切り替わるまで何も変化が起きないため UX が悪くなってしまいます。

解決方法

issue を調べてみるとどうやら同じ問題に遭遇している人はいくらか居るようでした。

https://github.com/vercel/next.js/issues/49297
https://github.com/vercel/next.js/issues/58179

issue が open になっているところからもまだ対応はされていないようです。
そこで issue の中に workaround を共有してくれている人が居たので解決がすることが出来ました。

自分で Suspense を設定する

loading.tsx は初回のみにしか動かないため自分で Suspense を設定します。
今回、例にする page.tsx は以下のようになっています。

src/app/search/page.tsx
import { getSearchProducts } from "@/usecase/product/server";
import { Product } from "./_components/Product";
import { Filter } from "@/usecase/product";

type Props = {
    searchParams: Filter
}

const Page: React.FC<Props> = async ({ searchParams }) => {
  const result = await getSearchProducts(searchParams);

  return (
    <div className="grid grid-cols-5 gap-4">
      {result.map((x) => {
        return <Product key={x.id} product={x} />;
      })}
    </div>
  );
};

export default Page;

このファイルを以下のように変更します。

src/app/search/page.tsx
 import { getSearchProducts } from "@/usecase/product/server";
 import { Product } from "./_components/Product";
 import { Filter } from "@/usecase/product";
+import { Suspense } from "react";
+import Loading from "./loading";
 
 type Props = {
   searchParams: Filter;
 };
 
 const Page: React.FC<Props> = async ({ searchParams }) => {
+  return (
+    <Suspense key={JSON.stringify(searchParams)} fallback={<Loading />}>
+      <Wrapper searchParams={searchParams} />
+    </Suspense>
+  );
+};
+
+const Wrapper: React.FC<Props> = async ({ searchParams }) => {
   const result = await getSearchProducts(searchParams);
 
   return (

ポイントは 2 点あります

1. 非同期なコンポーネントを分離する

今回の例の Wrapper のように非同期なコンポーネントを export しているコンポーネントとは別に作ります。
これは自分で設定する Suspense で包むために必要です。(Promise を返すコンポーネントが Suspense の子に居ないと Suspense されないため)

2. Suspense に key をつける

Suspense に key をつけることで、一度 suspend したものとは別のコンポーネントになるため再度 Suspense されます。(ここあたりの認識が曖昧なので間違っていたらコメント下さい🙇)
今回は searchParams の変更で都度 Suspense したいので searchParams を JSON 文字列にしたものを key にしています。
ここの key 設定は Loading を発火させたい要素を指定すると良さそうです。


この 2 点の修正を加えることで SearchParams を変更しても Loading を走らせることが出来ます!

注意点として、layout.tsx に Suspense を置くのではうまく動きません。
これは、page.tsx が先に実行されその後に layout.tsx が実行されるため layout.tsx の Suspense では page.tsx の Promise をキャッチ出来ないからです。

まとめ

workaround でしたが上記の 2 点の修正をすることで SearchParams を変更しても Loading を走らせることが出来ました。
詰まったときは 「なんで URL が変わってるのに Loading されないんだ🤔」と思いましたが、ドキュメントにも書いている通り

An instant loading state is fallback UI that is shown immediately upon navigation.

なのでナビゲーション直後の loading のみを扱うためのようです。(とは言え URL は変更されてるからナビゲーション直後な気もする...)
今回の workaround として変更を入れている部分は Streaming with Suspense で対応する場所なのかもしれません。

CoeFont

Discussion