Next.js v13 React Server Components で TanStackQuery のプリフェッチを使ってみる
CREATED: 2023 / 07 / 30 Sun
UPDATED: 2023 / 07 / 30 Sun
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>
)
}
を仕舞い
リポジトリにコード置いてます。