【Next.js】Suspenseの初歩的な使い方

Next.js

はじめに

こんにちは、株式会社TOKOSのツキヤです!
React18から、Suspenseが本格的に使えるようになりましたね。
適切に使うことでUXやパフォーマンス観点で有益な機能です✨
本記事では、そんなSuspenseをNext.jsと組み合わせて使う一般的な方法を説明します💪

Suspenseとは?

ひとことで簡単に言うと、「コンポーネントが読み込み(ロード)中の間に、別のコンポーネントを一時的に表示する仕組み」です!

では、「読み込み中」とはどんな状態でしょうか?🤔
これもひとことで言うと「Promiseが解決していない」データを持っている状態です!
簡単なコード例を見てみましょう!

page.jsx
export default function Page() {
  const response = fetch("https://jsonplaceholder.typicode.com/posts")

  return (
    <main>
      <Suspense fallback={<div>Loading...</div>}>
        <PostList response={response} />
      </Suspense>
      <footer>
        {/* 静的なフッターの内容 */}
      </footer>
    </main>
  )
}

上記のPostListというコンポーネントは、fetchした返り値のresponseという値をそのままpropsとして受け取っています。
また、fetchは非同期関数なので、返り値のresponsePromiseでラップされている状態です。(await等を使用していないため)
この時、PostListresponseの「Promiseが解決」するまではUIとして表示することができません🥶
この「Promiseが解決」するまでの間はSuspensefallbackの中の <div>Loading...</div> という文字が表示されることになるわけです!

この時、「静的なUIは即座にUIに表示させることができる」点が嬉しいポイントです✨
今回の例で言うと、footerは静的なUIなので、データのfetch等を待たずに即座に表示された方がユーザー体験としては嬉しいですよね🤭
Suspenseを使わずにすぐにawaitをするパターンだと、「Promiseが解決」されるまでは、画面内の全てのUIが表示されないことになってしまうのです😨

Promise(= 非同期)部分を適切にSuspenseで括ることで、使っていて気持ち良いサイトにすることができるのです!

Next.jsでSuspense対応をさせてみよう

それではざっくり理解が進んだところで、Next.jsでSuspenseを使っていきましょう!
流れとしては、「Suspenseを使わないバージョン」→「Suspenseを使うバージョン」で進めていきます。

前提知識

サーバーコンポーネントやRouteHandler等の理解をしている前提で説明していきます。
また、TypeScriptを使用しています。

ツキヤ
ツキヤ

分からない部分は必要に応じて調べていただけると幸いです🙏

環境

  • Next.js:15.2.4
  • React:19.1.0

エンドポイントの作成

まずはRouteHandlerを使ってエンドポイントを作成します!

src/app/api/posts/route.ts
import { NextResponse } from "next/server"

export type Post = {
  id: number
  title: string
  body: string
}

export async function GET() {
  // 3秒待機
  await new Promise((resolve) => setTimeout(resolve, 3000))

  return NextResponse.json([
    {
      id: 1,
      title: "最初の投稿",
      body: "これは最初の投稿の内容です。",
    },
    {
      id: 2,
      title: "2番目の投稿",
      body: "これは2番目の投稿の内容です。",
    },
    {
      id: 3,
      title: "3番目の投稿",
      body: "これは3番目の投稿の内容です。",
    },
  ])
}

データは静的に返すことにします。
ローディングしているかどうかを分かりやすく確認したいので、中で3秒程度待たせるようなロジックを入れています!

page.tsx・PostListコンポーネントの作成

次に呼び出す側のUIを作成します!

src/app/page.tsx
import { Post } from "@/app/api/posts/route"

export default async function Page() {
  const response = await fetch("http://localhost:3000/api/posts")
  const posts = (await response.json()) as Post[]

  return (
    <div className="min-h-screen bg-gray-50">
      <header className="bg-white shadow-sm">
        {/* 静的なヘッダーの内容 */}
      </header>

      <main className="mx-auto max-w-7xl px-8">
        <div className="mt-10 w-full max-w-4xl">
          <h2 className="mb-8 text-3xl font-bold text-gray-800">投稿一覧</h2>
          <PostList posts={posts} />
        </div>
      </main>
    </div>
  )
}
src/components/PostList.tsx
import { Post } from "@/app/api/posts/route"

type Props = {
  posts: Post[]
}

export function PostList({ posts }: Props) {
  return (
    <div className="space-y-6">
      <div className="grid gap-6">
        {posts.map((item: Post) => (
          <div
            key={item.id}
            className="rounded-lg border border-gray-200 bg-white p-6 shadow-md transition-shadow duration-100 hover:shadow-lg"
          >
            <h2 className="mb-2 text-xl font-semibold text-gray-700">{item.title}</h2>
            <p className="text-gray-600">{item.body}</p>
          </div>
        ))}
      </div>
    </div>
  )
}

これでUIは下記のようになりました!

今現在の状況が「Suspenseを使わないバージョン」になっています!
エンドポイント側で3秒待つようにしているので、ページにアクセスすると画面全体の描画までに時間がかかることが分かると思います🥶

Suspense有りで実装してみる

それでは、Suspense有りバージョンに変更してみます!
(変更がある部分のみ抜粋しています)

src/app/page.tsx(変更後)
export default async function Page() {
  const response = fetch("http://localhost:3000/api/posts")

  return (
    <div className="min-h-screen bg-gray-50">
      {/* ... */}
      <main className="mx-auto max-w-7xl px-8">
        <div className="mt-10 w-full max-w-4xl">
          <h2 className="mb-8 text-3xl font-bold text-gray-800">投稿一覧</h2>
          <Suspense
            fallback={
              <div className="space-y-6">
                <div className="grid gap-6">
                  {[1, 2, 3].map((i) => (
                    <div
                      key={i}
                      className="rounded-lg border border-gray-200 bg-white p-6 shadow-md"
                    >
                      <div className="mb-2 h-6 w-3/4 animate-pulse rounded bg-gray-200"></div>
                      <div className="h-4 w-full animate-pulse rounded bg-gray-200"></div>
                    </div>
                  ))}
                </div>
              </div>
            }
          >
            <PostList responsePromise={response}/>
          </Suspense>
        </div>
      </main>
    </div>
  )
}
src/components/PostList.tsx(変更後)
type Props = {
  responsePromise: Promise<Response>
}

export function PostList({ posts }: Props) {
  const response = await responsePromise
  const posts = (await response.json()) as Post[]
  
  return (
    <div className="space-y-6">
      {/* 同じ内容 */}
    </div>
  )
}

page.tsx側でawaitしていた部分をPostList.tsx側で行うようにした点と、Suspense(+fallback)コンポーネントを追加した部分が変更点となります!
こうすることで、Promiseを使用していない部分は即時でUIに描画され、PostListの3秒待っている間はfallbackのUIが表示されることになります💪

イメージとしては、「Promiseを解決」する部分を子コンポーネントにズラした形です!

サーバーコンポーネントでの使用方法

…と書いたのですが、上記での実装自体がサーバーコンポーネントでの使用方法になっています!
具体的には、コンポーネント自体はasyncな関数コンポーネントにした上で、中でawaitを使うだけです。

応用として、先ほどのコードを改善してみます。
結論としては、propsで渡すのでは無く子コンポーネント側で直接fetchをしてしまいます!

src/components/PostList.tsx(改善Ver.)
// propは不要!!
// type Props = {
//   responsePromise: Promise<Response>
// }

export function PostList() {
  const response = await fetch("http://localhost:3000/api/posts")
  const posts = (await response.json()) as Post[]
  
  return (
    <div className="space-y-6">
      {/* 同じ内容 */}
    </div>
  )
}

こうすることで、propsの受け渡しすら必要無くなります💪
(page.tsx側はfetch部分の記述をする必要が無くなります)

また、もし他のコンポーネントかもこのエンドポイントを叩きたいとなっても、Next.jsにはRequest Memoizationという機能があるので、重複した通信は無くなります✨

クライアントコンポーネントでの使用方法

ボタン等インタラクティブなコンポーネントの場合はサーバーコンポーネントを使えない = awaitを使うことができません😨
そんな場合(=クライアントコンポーネント)でもSuspenseを使う方法(≒「Promiseの解決方法」)を解説します。

端的に言うと、「use」を使うのが一番手っ取り早いです!
まずはコードを見てみます!

src/app/page.tsx(クライアントコンポーネントにわたすVer.)
export default async function Page() {
  const response = fetch("http://localhost:3000/api/posts")

  return (
    <div className="min-h-screen bg-gray-50">
      {/* ... */}
      <main className="mx-auto max-w-7xl px-8">
        <div className="mt-10 w-full max-w-4xl">
          <h2 className="mb-8 text-3xl font-bold text-gray-800">投稿一覧</h2>
          <Suspense
            fallback={/* ... */}
          >
            <PostList responsePromise={response.then((res) => res.json())}/>
          </Suspense>
        </div>
      </main>
    </div>
  )
}
src/components/PostList.tsx(クライアントコンポーネントVer.)
"use client"

import { use } from "react"
import { Post } from "@/app/api/posts/route"

type Props = {
  responsePromise: Promise<Post[]>
}

export function PostList({ responsePromise }: Props) {
  // ここ!!
  const posts = use(responsePromise)

  return (
    <div className="space-y-6">
      {/* 同じ内容 */}
    </div>
  )
}

変更としては、親側でresponse.then((res) => res.json())としてthenの記述を追加し、子側のクライアントコンポーネントでuseを使用しているだけです!

use」をひとことで言うと、「Promiseを解決する」hookです!
本記事で度々出ている「Promiseを解決する」というワードにピッタリのhookですね😎

ツキヤ
ツキヤ

use」はReact19からの新機能です✨

このように、サーバーコンポーネントでもクライアントコンポーネントでもSuspenseを使ってユーザー体験を向上することができます💪

おわりに

Suspenseの利点としては他にも、「Promise.allが不要になる」「ローディング中のUIをfallbackに任せられるためDX(Developer Experience)が向上する」等もあったりします!
また、紹介したuseを使わなくても、SWRやTanStack Queryのようなライブラリを使うことでよりスマートに記述することも可能です😆

Suspenseのベストプラクティスは定まってないように思うので、本記事を参考にした上でぜひカスタマイズしてみてください!
基本は「より子コンポーネント側でPromiseを解決させるべき」だと思っています🤔

また、ページ遷移自体を高速にしたい場合はloading.tsxを使用することも検討した方が良いかもしれないです。
僕もケースバイケースで考えて行きたいです💪