空雲 Blog

Next.js の AppRouter で React Query を用いた SSR(Server Component不使用)

publication: 2024/12/09
update:2024/12/09

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";
10
11const DATA_NAME = "__REACT_QUERY_DATA_PROMISE__";
12
13type PropertyType = {
14 finished?: boolean;
15 promises?: Promise<unknown>[];
16};
17
18const DataTransfer: FC<{ property: PropertyType }> = ({ property }) => {
19 const queryClient = useQueryClient();
20 const promises = queryClient
21 .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 <script
31 id={DATA_NAME}
32 type="application/json"
33 dangerouslySetInnerHTML={{
34 __html: JSON.stringify(value).replace(/</g, "\\u003c"),
35 }}
36 />
37 );
38};
39
40export 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};
58
59export const enableSSR = { suspense: isServer };

実装例

ポケモンの情報を表示するサンプルです。初回アクセス時はデータをサーバ側で処理して SSR し、その後の UI 操作によるアクションはクライアント側でデータを取得しています。

通常の React Query に追加する作業は、SSRProvider を挟み込むのと、useQuery に enableSSR オプションを追加するだけです。

app/Provider.tsx

React Query の Provider を作成します。staleTime を設定しないと、サーバで作ったデータがクライアントで破棄されてしまうので注意してください。さらに先程作った SSRProvider を差し込んで、サーバで作ったデータをクライアントに転送する機能を追加します。

1"use client";
2
3import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4import { FC, ReactNode, useState } from "react";
5import { SSRProvider } from "react-query-ssr";
6
7export 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";
2
3export 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";
4
5import Link from "next/link";
6import { useParams } from "next/navigation";
7
8type PokemonList = {
9 count: number;
10 next: string;
11 previous: string | null;
12 results: { name: string; url: string }[];
13};
14
15const pokemonList = (page: number): Promise<PokemonList> =>
16 fetch(`https://pokeapi.co/api/v2/pokemon/?offset=${(page - 1) * 20}`).then(
17 (r) => r.json()
18 );
19
20const 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 });
29
30 if (!data) return <div>loading</div>;
31 return (
32 <>
33 <title>Pokemon List</title>
34 <div style={{ display: "flex", gap: "8px", padding: "8px" }}>
35 <Link
36 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 <Link
46 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";
6
7type 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};
27
28const pokemon = (name: string): Promise<Pokemon> =>
29 fetch(`https://pokeapi.co/api/v2/pokemon/${name}`).then((r) => r.json());
30
31const 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 });
40
41 if (!data) return <div>loading</div>;
42 return (
43 <>
44 <title>{name}</title>
45 <div style={{ padding: "8px" }}>
46 <Link
47 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 ⏪️List
56 </Link>
57 </div>
58 <hr style={{ margin: "24px 0" }} />
59 <div
60 style={{
61 display: "inline-flex",
62 flexDirection: "column",
63 alignItems: "center",
64 padding: "8px",
65 }}
66 >
67 <img
68 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 を使ってコンポーネントの評価順を制御し、データの出力を行いました。また、データルーティングを行うことで、サーバ側のデータをクライアント側で再利用しています。

フレームワークの多機能化が進んでいますが、ベンダーロックインを防ぐためにも、標準機能でなんとかしていきたいところです。