0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

useSearchParams() でハマる Suspense 地獄と対処法【Next.js App Router】

Posted at

useSearchParams() でハマる Suspense 地獄と対処法【Next.js App Router】

はじめに

Next.js(App Router)で useSearchParams() を使った瞬間、「Missing a Suspense boundary」 でページが爆散——
この“Suspense地獄”は、クライアントコンポーネントでルーター状態を読むときは非同期境界(<Suspense>)が必要という仕様に起因します。
この記事は、よくある落とし穴→最短の修正→ベストプラクティス、の順に整理した実務向けサバイバルガイドです。


0. TL;DR(最短修正)

  • useSearchParams()呼び出すコンポーネント<Suspense> で包む。
  • もしくは Server Component の searchParams 引数で受け取り、Client に props で渡す
  • ルール:**「フックを呼ぶ場所」**に Suspense が必要。親ページに置くだけでは足りないケースあり。

1. 典型的なエラーメッセージ

Error: useSearchParams() is missing a Suspense boundary.

または

You're attempting to read the router state without a suspense boundary.

2. なぜ Suspense が必要なのか

  • useSearchParams() は **クライアント側でルーター状態(URLクエリ)**を読みます。
  • ルーター状態は 遷移時に非同期に変化するため、React は「待機点(Suspense boundary)」がないと安全にレンダーできません。
  • つまり 「待つ場所(<Suspense>)を用意せよ」 が要件。

サーバーコンポーネントで searchParams を受ければ、SSRの確定値として扱えるので Suspense 不要。
クライアントで直接読むなら、遷移と同時に変わるので Suspense が必要。


3. 最小再現コード(NG例)

// app/items/page.tsx (Server Component)
import { ItemList } from "./_components/ItemList";

export default function Page() {
  return <ItemList />; // ← 子が useSearchParams を呼ぶ
}

// app/items/_components/ItemList.tsx (Client Component)
"use client";
import { useSearchParams } from "next/navigation";

export function ItemList() {
  const sp = useSearchParams(); // ← ここで爆発
  const q = sp.get("q") ?? "";
  return <div>Query: {q}</div>;
}

4. 解決策その1:呼び出し元を <Suspense> で包む(最短)

// app/items/page.tsx
import { Suspense } from "react";
import { ItemList } from "./_components/ItemList";

export default function Page() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <ItemList />
    </Suspense>
  );
}

ポイント

  • useSearchParams()実際に呼ぶコンポーネント<Suspense> 配下に置くこと。
  • ページ上位に <Suspense> を置いても、境界の外で読んでいれば無効。

5. 解決策その2:Server Component で受けて Client に props で渡す(堅牢)

// app/items/page.tsx (Server Component)
import { ItemListClient } from "./_components/ItemListClient";

export default function Page({ searchParams }: {
  searchParams?: { [key: string]: string | string[] | undefined }
}) {
  const q = typeof searchParams?.q === "string" ? searchParams.q : "";
  return <ItemListClient q={q} />; // ← 確定値として渡す
}

// app/items/_components/ItemListClient.tsx (Client Component)
"use client";

export function ItemListClient({ q }: { q: string }) {
  return <div>Query: {q}</div>;
}

メリット

  • クライアント側では普通の propsとして扱えるため、Suspense 不要。
  • SSR キャッシュや SEO 面でも安定。

6. 解決策その3:呼び出し箇所の“直上”に Suspense 境界を置く

// app/items/_components/ItemList.tsx (Client)
"use client";
import { Suspense } from "react";
import { useSearchParams } from "next/navigation";

function Inner() {
  const sp = useSearchParams();
  const q = sp.get("q") ?? "";
  return <div>Query: {q}</div>;
}

export function ItemList() {
  return (
    <Suspense fallback={<div>Loading list...</div>}>
      <Inner />
    </Suspense>
  );
}

解説

  • 呼び出し関数を分割し、useSearchParams を呼ぶ関数の直上に Suspense を置く。
  • 複雑なレイアウトで「どこに境界を置けば良いか」迷ったときの実務テク。

7. URL同期UIでの実用レシピ(検索フォーム+クエリ連動)

// app/search/page.tsx (Server)
import { Suspense } from "react";
import { SearchPanel } from "./_components/SearchPanel";

export default function Page() {
  return (
    <Suspense fallback={<div>Loading search...</div>}>
      <SearchPanel />
    </Suspense>
  );
}

// app/search/_components/SearchPanel.tsx (Client)
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo } from "react";

export function SearchPanel() {
  const router = useRouter();
  const sp = useSearchParams();

  const q = sp.get("q") ?? "";
  const handleSubmit = useCallback(
    (e: React.FormEvent<HTMLFormElement>) => {
      e.preventDefault();
      const form = new FormData(e.currentTarget);
      const next = new URLSearchParams(sp); // 既存を引き継ぐ
      next.set("q", String(form.get("q") ?? ""));
      router.push(`/search?${next.toString()}`);
    },
    [router, sp]
  );

  const placeholder = useMemo(() => (q ? `Now: ${q}` : "Type query..."), [q]);

  return (
    <form onSubmit={handleSubmit}>
      <input name="q" defaultValue={q} placeholder={placeholder} />
      <button type="submit">Search</button>
    </form>
  );
}

コツ

  • URLSearchParams(sp)既存クエリを保ったまま編集。
  • ルーター遷移が起きるため、Suspense 必須(Page 側で包む)。

8. 「包んだけどまだ怒られる」時のデバッグ手順

  1. どのコンポーネントで useSearchParams を呼んでいるか特定する。
  2. その 直上に <Suspense> があるか 確認(祖先に置けば良いわけではない)。
  3. useSearchParams関数外で参照していないか(例:モジュールスコープで呼ぶのはNG)。
  4. Server/Client の境界が正しいか("use client" の位置が過不足ないか)。
  5. それでもダメなら 解決策その2(Serverで受けてprops渡し)に切替

9. よくある設計の分岐基準

  • SEO必要/SSRで確定値が欲しいServerの searchParams で受ける(推奨)
  • ClientだけでURL状態を直接読みたいUI最小範囲で <Suspense> を被せる
  • 表層は安定・内側でURL連動「呼ぶ場所の直上」に境界

10. 型定義テンプレ(コピペ用)

// page.tsx(Server Component)での型
export default function Page({
  searchParams,
}: {
  searchParams?: { [key: string]: string | string[] | undefined };
}) {
  // 型安全に取り出す例
  const q =
    typeof searchParams?.q === "string"
      ? searchParams.q
      : Array.isArray(searchParams?.q)
      ? searchParams!.q[0] ?? ""
      : "";
  // ...
}

まとめ

  • useSearchParams()クライアントで使うなら Suspense 必須
  • 最も堅いのは Server で受けて props で渡す設計。
  • 迷ったら「フックを呼ぶ場所の直上に境界」を置く。
    これで“Suspense地獄”から脱出できます。ハッピーURLライフを。
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?