header image

枝折

Next.js v13 React Server Components で TanStackQuery のプリフェッチを使ってみる

CREATED: 2023 / 07 / 30 Sun

UPDATED: 2023 / 07 / 30 Sun

Next.js v13 の React Server Components でサーバー側でのプリフェッチを TanStackQuery で実装してみます。

Next.js v13 から React Server Components が標準に

Next.js v13 からは App Router が Recommended となっており、React Server Component(RSC) がデフォルトで利用できるようになっています。 RSC はめちゃくちゃ簡単に言うと React の側を部分的にサーバーで作って、他をクライアントで組み立てるもの、ってな感じです。 こうすることでクライアントへ渡す JS のサイズを抑えることができて、さらに処理を高速化することができるわけです。

サーバー側で React の側を作ることの利点としては、DB などのデータソースへ直接アクセスしたりすることができるので、ネットワークのボトルネックを抑えることができるといった点でしょうか。 とは言っても、別に稼働するサーバーへリクエストを投げるような構成ではネットワーク上でのやり取りが発生しますが、これまでのクライアントからのユーザーインタラクションをトリガーとするリクエストの経路はカットされそうな感じですね。

そういったサーバーならではの処理を行う必要がない場合はクライアントでレンダリングすれば良いと思います。 基本的にはそのコンポーネントではユーザーのインタラクションが入るとか、もっと動的に UX を向上させたいような場合に use client 宣言をしてクライアント上でステート管理やらをすれば良いかと。

サーバーでプリフェッチしてクライアントで使う

RSC についてはそんなところで、じゃあサーバーコンポーネントでサーバーのデータを取得するとして、それが使えないと意味がないですね。 なので、TanStack Query を利用してサーバーコンポーネントでデータを取得、それをクライアントで利用する方法を考えてみましょう。 React 18 から Suspense も stable になりましたし、これと組み合わせてデータが揃うまではロード中画面を出せればより良いですね。

と言うことでここでは以下を紹介します。

- TanStack Query を用いて RSC のサーバーコンポーネント側でデータのプリフェッチを行う方法
  - `initialData` を利用する方法
  - `<Hydrate>` を利用する方法
- Suspense を利用してロード中画面を表示する方法

クライアントでのデータ取得については、Route Handlers の方法で GET 関数などを作成してクライアントコンポーネントから呼び出せば良いと思います。 Route Handlers

Next.js v13 でプロジェクトを作成

まず Next.js プロジェクトを作成します。

$ npx create-next-app@latest
 What is your project named?  next-13-tanstack-query-prefetching
 Would you like to use TypeScript?  No / Yes # Yes
 Would you like to use ESLint?  No / Yes # Yes
 Would you like to use Tailwind CSS?  No / Yes # Yes
 Would you like to use `src/` directory?  No / Yes # Yes
 Would you like to use App Router? (recommended) … No / Yes # Yes
 Would you like to customize the default import alias?  No / Yes # No

で、作成されたディレクトリをエディタで開いて、npm run dev で起動します。

これやる前にここで SyntaxError: Unexpected token '??=' って言うエラーが出て、なんで??ってなったのですが、 私の場合は Node.js のバージョンが低かったためにこのエラーが出ていたようでした(v18 なら動くのですが、v14 を使っていました、、、)。 同じエラーで引っかかる人はいないかと思いますが一応書き残しておきます。

そしたらまずは TanStack Query をインストールしましょう。

$ npm i @tanstack/react-query

後肝心の API ですが、前回の Flutter の時と同じく、PokeAPI を使おうと思います。 Poke API

呼び出すのはこの リソース https://pokeapi.co/api/v2/pokemon

これを使ってポケモンの一覧をフシギダネから順番に取得することができます。

それでは API を呼び出す関数を作成してみようと思います。

サーバーコンポーネントでのプリフェッチを TanStack Query で行うには方法が2つあります。 initialData を props としてクライアントコンポーネントへ渡す方法と <Hydrate> を利用する方法です。

どちらの方法を取るとしても、まず下準備をしましょう。 TanStack Query が内部的に必要としている QueryClient を取得するために QueryClientProvider を上位に配置しておく必要があります。

// 下準備
// app/providers.tsx
'use client'
import { useState, ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
export default function Providers({ children }: { children: ReactNode }) {
  const [queryClient] = useState(() => new QueryClient())
  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  )
}

// app/layout.tsx
import Providers from './providers'
import { ReactNode } from 'react'
export default function RootLayout({
  children,
}: {
  children: ReactNode
}) {
  return (
    <html lang="en">
      <head />
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

initialData を使う方法

app/api/index.ts を作成し、その中で API を呼び出します。 ディレクトリなどはなんでも良いです。

// app/api/index.ts
export async function fetchPokemons() {
  const res = await fetch(
    'https://pokeapi.co/api/v2/pokemon',
    { headers: {'Content-Type': 'application/json'} }
  )
  const data = await res.json()

  return data
}

ここでは別サーバーで API が提供されていると言う前提でやっていますが、この fetchPokemons が直接 DB へアクセスするなど別の方法でデータを取得するのでも問題ないかと思います。 また、そのような複合的なデータソースを扱うかどうかとは別に、この層で取得したデータを他のどのコンポーネントでも同様のルールで扱えるような整形処理(メタデータやページネーションの情報などを含めたオブジェクトとしてまとめるなど)などがあれば良さそうですね。 でこの関数を app/components/page.tsx (サーバーコンポーネント)から呼び出します。

// app/components/page.tsx
import { fetchPokemons } from "../api/index"
import Pokemons from "./pokemons/page"

export default async function Pokemon() {
  const initialData = await fetchPokemons()

  return <Pokemons pokemons={initialData} />
}

app/components/pokemons/page.tsx にはクライアントコンポーネントである <Pokemons /> を作成しています、以下のように。

// app/components/pokemons/page.tsx
'use client'

import { useQuery } from '@tanstack/react-query'
import { fetchPokemons } from "../../api/index"

export default function Pokemons(props: { pokemons: any }) {
  const { data } = useQuery({
    queryKey: ['pokemons'],
    queryFn: fetchPokemons,
    initialData: props.pokemons,
  })

  // data の中からデータを取得する
  return (
    <section className="w-[400px]">
      <p>initialData によるプリフェッチ</p>
      {
        data.results.map((result: { name: string }) => (
          <dl key={result.name} className="h-[120px]">
            <dt>
              <p>{result.name}</p>
            </dt>
            <dd>DESCRIPTION</dd>
          </dl>
        ))
      }
    </section>
  )
}

これでサーバーコンポーネントで取得したデータをクライアントコンポーネントに出力することができるようになりました。 data.results の中にポケモンのリストが入っているので map などで展開することができます。

<Hydrate> を利用する方法

公式の説明だと、QueryClient をキャッシュするようにしているようです。 Using <Hydrate> | TanStack Query Docs

これによりリクエストスコープ単位でのシングルトンとなった QueryClient が作成され、異なるユーザーやリクエスト間でこの QueryClient が共有されなくなります。 ちなみにこれは後述するサーバーコンポーネントで呼び出され、リクエストごとに一度だけ作られるようです。

// app/getQueryClient.tsx
import { QueryClient } from '@tanstack/react-query'
import { cache } from 'react'

const getQueryClient = cache(() => new QueryClient())
export default getQueryClient

そしたらこれを app/components/hydrate/pages.tsx で利用して、prefetchQuery でデータをプリフェッチします。

// app/components/hydrate/pages.tsx
import { dehydrate, Hydrate } from '@tanstack/react-query'
import getQueryClient from '../../getQueryClient'
import { fetchPokemons } from '../../api/index'
import Pokemons from './pokemons/page'

export default async function HydratedPosts() {
  const queryClient = getQueryClient()
  await queryClient.prefetchQuery(['pokemons'], fetchPokemons)
  const dehydratedState = dehydrate(queryClient)

  return (
    <Hydrate state={dehydratedState}>
      <Pokemons />
    </Hydrate>
  )
}

ここの <Hydrate> の中で呼び出している <Pokemons /> はクライアントコンポーネントです。

// app/components/hydrate/pokemons/page.tsx
'use client'

import { useQuery } from '@tanstack/react-query'
import { fetchPokemons } from '../../../api/index'

export default function Pokemons() {
  // サーバーコンポーネント側でプリフェッチしたデータを即座に利用できる
  const { data } = useQuery({ queryKey: ['pokemons'], queryFn: fetchPokemons })

  // 以下の方法でもデータを取得することができるが、クライアントでレンダリングされるまでフェッチは実行されない
  // const { data: otherData } = useQuery({
  //   queryKey: ['pokemons-2'],
  //   queryFn: fetchPokemons,
  // })

  // data の中からデータを取得する
  return (
    <section className="w-[400px]">
      <p>Hydrate によるプリフェッチ</p>
      {
        data.results.map((result: { name: string }) => (
          <dl key={result.name} className="h-[120px]">
            <dt>
              <p>{result.name}</p>
            </dt>
            <dd>DESCRIPTION</dd>
          </dl>
        ))
      }
    </section>
  )
}

データ取れましたね。

React 18 の Suspense を導入

やることは Suspense インポートして layout.tsx で使うだけです。

// app/layout.tsx
import { Suspense } from 'react'
// .
// .
// .
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Suspense fallback={<div>Loading...</div>}>
          <Providers>
            {children}
          </Providers>
        </Suspense>
      </body>
    </html>
  )
}

PokeAPI のデータは一瞬で取得されるのでわかりづらいですが、setTimeout すれば時間中は Loading… がちゃんと表示されるかと思います。

// app/components/hydrate/page.tsx
import { dehydrate, Hydrate } from '@tanstack/react-query'
import getQueryClient from '../../getQueryClient'
import { fetchPokemons } from '../../api/index'
import Pokemons from './pokemons/page'
import { setTimeout } from 'timers/promises'

export default async function HydratedPosts() {
  await setTimeout(5000) // 5 秒待つ
  const queryClient = getQueryClient()
  await queryClient.prefetchQuery(['pokemons'], fetchPokemons)
  const dehydratedState = dehydrate(queryClient)

  return (
    <Hydrate state={dehydratedState}>
      <Pokemons />
    </Hydrate>
  )
}

を仕舞い

リポジトリにコード置いてます。

yutaro1204 / next13-tanstackquery-prefetch