Next.js の AppRouter で React Query を用いた SSR(Server Component不使用)
React の SSR にフレームワークの機能は必要ない
React で SSR を行う際、フレームワークの機能を使わずに React の標準機能だけで実現する方法を紹介します。React Router(Remix) でも同じ方法が有効なので、これを使えば Next.js への依存が最小になります。
Next.js での一般的な方法だと App Router で SSR を行う場合、データを Server Component で取得する必要があります。この方法だと、取得したデータをクライアント側で動的に制御したい場合、Server Component と Client Component を連結させた二重構造にする必要があります。
実はそんな方法を使わずとも React には、Client Component でもデータを取得する機能が用意されています。Server Component を使わずに実現可能なのです。
React の標準機能で SSR を行う際の必要なテクニック
throw promise
コンポーネントで外部にあるデータを持ってくる際は、非同期という扱いになります。React の一般コンポーネントを SSR でレンダリングする場合は、同期的に実行されなければなりません。ではどうやって非同期処理を同期的に扱うかというと、throw promise を使います。throw promise、データが出揃うまでコンポーネントの評価を一旦スキップすることができます。この機能により、コンポーネントの評価タイミングを自由に調整し、実態は非同期なのに、コンポーネントは同期状態という形で SSR が可能になります。
この機能、各ライブラリで suspense という名前で提供されていますが、React の Suspense は一切使う必要はありません。逆に、使ってしまうと意図通り動かなくなるので注意が必要です。Suspense が必要なのは Streaming SSR の場合ですが、今回はこの機能を利用しません。
データルーティング
サーバ側で必要なデータを取得
そのデータを HTML に変換して出力
クライアント側でその HTML を受け取り、仮想 DOM を構築し対応したノードをマウント
クライアント側で再レンダリング ← ここで問題が発生
SSR ではサーバ側で必要なデータを揃えて、それを HTML に変換して出力します。クライント側ではその HTML を受け取った後に、仮想 DOM を構築し対応したノードをマウントします。そのままだと、マウント完了後の再レンダリング時に問題が発生します。サーバ側が持っていたデータが何なのかクライアントは知らないからです。HTML の中にはデータが入っていても、クライアントで実行されるスクリプト側にはデータが入っていないのです。すると、空データで再レンダリングされてしまい、せっかくサーバ側で吐き出したデータが消えてしまいます。
これに対処するには、サーバ側のデータをクライアントが受け取れるようにします。具体的な方法としては、データを JSON 化して HTML に埋め込みます。クライアント側はその JSON データを取得し、それを使って再レンダリングを行います。これにより、サーバ側で取得したデータをクライアント側で再利用することができます。
サーバーとクライアントの処理
throw promise によるコンポーネントの評価順を制御することで、データが出揃うのを待つコンポーネントを作ることが出来ます。このコンポーネントでデータの JSON 化してレンダリングすることでサーバ側の出力は完了です。
クライアントは、サーバ側で出力された JSON データを取得し、初期データとして設定します。その後、クライアント側で再レンダリングを行います。この時、初期データがあるため、再レンダリング時にデータが消えることはありません。
React Query を SSR 化する
前述の内容を踏まえて、React Query を SSR 化する方法を紹介します。私の認識では React Query は多機能な非同期データキャッシュの管理ライブラリです。このデータキャッシュ機構を利用して、SSR 化させてみます。
こちらが npm に公開しているライブラリです
https://www.npmjs.com/package/react-query-ssr
サーバ上では React Query で発生した Promise 完了を待ってキャッシュが完成するのを待ち、dehydrate で取り出したデータを初期 HTML で書き出します。そしてクライアント側で HTML 上からデータを受け取り、hydrate して React Query のキャッシュに送ります。
1"use client";2import {3 isServer,4 dehydrate,5 hydrate,6 useQueryClient,7} from "@tanstack/react-query";8import React, { ReactNode } from "react";9import { FC, useRef } from "react";1011const DATA_NAME = "__REACT_QUERY_DATA_PROMISE__";1213type PropertyType = {14 finished?: boolean;15 promises?: Promise<unknown>[];16};1718const DataTransfer: FC<{ property: PropertyType }> = ({ property }) => {19 const queryClient = useQueryClient();20 const promises = queryClient21 .getQueryCache()22 .getAll()23 .flatMap(({ promise }) => (promise ? [promise] : []));24 if (isServer && !promises.every((p) => property.promises?.includes(p))) {25 property.promises = promises;26 throw Promise.all(promises);27 }28 const value = dehydrate(queryClient);29 return (30 <script31 id={DATA_NAME}32 type="application/json"33 dangerouslySetInnerHTML={{34 __html: JSON.stringify(value).replace(/</g, "\\u003c"),35 }}36 />37 );38};3940export const SSRProvider: FC<{ children: ReactNode }> = ({ children }) => {41 const queryClient = useQueryClient();42 const property = useRef<PropertyType>({}).current;43 if (!isServer && !property.finished) {44 const node = document.getElementById(DATA_NAME);45 if (node) {46 const value = JSON.parse(node.innerHTML);47 hydrate(queryClient, value);48 }49 property.finished = true;50 }51 return (52 <>53 {children}54 <DataTransfer property={property} />55 </>56 );57};5859export const enableSSR = { suspense: isServer };
実装例
Vercel でのデモ
https://next-react-query-ssr.vercel.app/
ポケモンの情報を表示するサンプルです。初回アクセス時はデータをサーバ側で処理して SSR し、その後の UI 操作によるアクションはクライアント側でデータを取得しています。
通常の React Query に追加する作業は、SSRProvider を挟み込むのと、useQuery に enableSSR オプションを追加するだけです。
app/Provider.tsx
React Query の Provider を作成します。staleTime を設定しないと、サーバで作ったデータがクライアントで破棄されてしまうので注意してください。さらに先程作った SSRProvider を差し込んで、サーバで作ったデータをクライアントに転送する機能を追加します。
1"use client";23import { QueryClient, QueryClientProvider } from "@tanstack/react-query";4import { FC, ReactNode, useState } from "react";5import { SSRProvider } from "react-query-ssr";67export const Provider: FC<{ children: ReactNode }> = ({ children }) => {8 const [queryClient] = useState(9 () =>10 new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000 } } })11 );12 return (13 <QueryClientProvider client={queryClient}>14 <SSRProvider>{children}</SSRProvider>15 </QueryClientProvider>16 );17};
app/Layout.tsx
Provider を使ってページをラップします。
1import { Provider } from "./Provider";23export default function RootLayout({4 children,5}: {6 children: React.ReactNode;7}) {8 return (9 <html lang="en">10 <body>11 <Provider>{children}</Provider>12 </body>13 </html>14 );15}
src/app/[page]/page.tsx
ポケモンの一覧を表示するサンプルです。普通に useQuery を使っているだけのように見えますが、これだけで SSR 化されます。サーバ・クライアント間のデータ共有は自動で行われます。
追加事項はオプションに enableSSR を追加していることです。これを入れなかった場合、サーバ側ではデータが取得されなくなります。React Query には元々こんなオプションはありません。実際に何をやっているのかというと、サーバ側では suspense フラグを有効にして、クライアントでは無効にしています。ReactQuery@5 では、useQuery の suspense フラグ自体が型情報から削除されていますが実際は使えます。
React@19 からはタイトルやメタ情報をコンポーネントの中に書いても、SSR 時にに移動してくれる機能が追加されました。これにより、フレームワークの力を借りなくとも、React の標準機能だけ内の情報を管理することが可能になりました。
1"use client";2import { useQuery } from "@tanstack/react-query";3import { enableSSR } from "react-query-ssr";45import Link from "next/link";6import { useParams } from "next/navigation";78type PokemonList = {9 count: number;10 next: string;11 previous: string | null;12 results: { name: string; url: string }[];13};1415const pokemonList = (page: number): Promise<PokemonList> =>16 fetch(`https://pokeapi.co/api/v2/pokemon/?offset=${(page - 1) * 20}`).then(17 (r) => r.json()18 );1920const Page = () => {21 const params = useParams();22 const page = Number(params["page"] ?? 1);23 const { data } = useQuery({24 // `useQuery` with this option.25 ...enableSSR,26 queryKey: ["pokemon-list", page],27 queryFn: () => pokemonList(page),28 });2930 if (!data) return <div>loading</div>;31 return (32 <>33 <title>Pokemon List</title>34 <div style={{ display: "flex", gap: "8px", padding: "8px" }}>35 <Link36 href={page > 1 ? `/${page - 1}` : ""}37 style={{38 textDecoration: "none",39 padding: "8px",40 boxShadow: "0 0 8px rgba(0, 0, 0, 0.1)",41 }}42 >43 ⏪️44 </Link>45 <Link46 href={page < Math.ceil(data.count / 20) ? `/${page + 1}` : ""}47 style={{48 textDecoration: "none",49 padding: "8px",50 boxShadow: "0 0 8px rgba(0, 0, 0, 0.1)",51 }}52 >53 ⏩️54 </Link>55 </div>56 <hr style={{ margin: "24px 0" }} />57 <div>58 {data.results.map(({ name }) => (59 <div key={name}>60 <Link href={`/pokemon/${name}`}>{name}</Link>61 </div>62 ))}63 </div>64 </>65 );66};67export default Page;
src/app/pokemon/[name]/page.tsx
こちらは、ポケモンの詳細を表示するサンプルです。こちらも enableSSR を追加しています。
1"use client";2import { useQuery } from "@tanstack/react-query";3import Link from "next/link";4import { useParams } from "next/navigation";5import { enableSSR } from "../../react-query-ssr";67type Pokemon = {8 abilities: { ability: { name: string; url: string } }[];9 base_experience: number;10 height: number;11 id: number;12 name: string;13 order: number;14 species: { name: string; url: string };15 sprites: {16 back_default: string;17 back_female: string;18 back_shiny: string;19 back_shiny_female: string;20 front_default: string;21 front_female: string;22 front_shiny: string;23 front_shiny_female: string;24 };25 weight: number;26};2728const pokemon = (name: string): Promise<Pokemon> =>29 fetch(`https://pokeapi.co/api/v2/pokemon/${name}`).then((r) => r.json());3031const Page = () => {32 const params = useParams();33 const name = String(params["name"]);34 const { data } = useQuery({35 // `useQuery` with this option.36 ...enableSSR,37 queryKey: ["pokemon", name],38 queryFn: () => pokemon(name),39 });4041 if (!data) return <div>loading</div>;42 return (43 <>44 <title>{name}</title>45 <div style={{ padding: "8px" }}>46 <Link47 href="/1"48 style={{49 textDecoration: "none",50 padding: "8px 32px",51 boxShadow: "0 0 8px rgba(0, 0, 0, 0.1)",52 borderRadius: "8px",53 }}54 >55 ⏪️List56 </Link>57 </div>58 <hr style={{ margin: "24px 0" }} />59 <div60 style={{61 display: "inline-flex",62 flexDirection: "column",63 alignItems: "center",64 padding: "8px",65 }}66 >67 <img68 style={{ boxShadow: "0 0 8px rgba(0, 0, 0, 0.5)" }}69 src={data.sprites.front_default}70 />71 <div>{name}</div>72 </div>73 </>74 );75};76export default Page;
実行結果
初期 HTML にデータが含まれていることが確認できます。
まとめ
React Router(Remix) や Next.js などのフレームワーク種類に関係なく、React の SSR にフレームワークの機能は必要ありません。React の標準機能だけで実現することが可能です。この記事では、throw promise を使ってコンポーネントの評価順を制御し、データの出力を行いました。また、データルーティングを行うことで、サーバ側のデータをクライアント側で再利用しています。
フレームワークの多機能化が進んでいますが、ベンダーロックインを防ぐためにも、標準機能でなんとかしていきたいところです。