空雲 Blog

Eye catchNext.js+urqlでGraphQLデータをコンポーネント上の hookのみでSSRさせる

publication: 2023/01/09
update:2024/02/20

Next.js の SSR は面倒くさい

SSR を考えない場合、外部から取ってきた非同期データのレンダリングは、コンポーネント内に取得処理を置いて、受け取れた時点でデータを表示という流れで比較的簡単に記述できます。しかし SSR で初期 HTML に非同期データを加えようと思うと、一気に面倒になります。

何故面倒なのかと言えば、Next.js の初期レンダリングは非同期に対応していなかったからです。そのため、一般的な方法で SSR をやろうとするとgetInitialPropsgetServerSidePropsを使って非同期データを収集し、コンポーネントにデータを引き渡す流れになります。

この構成の問題点は、クライアントで再フェッチが必要になったときに、サーバ側とクライアント側で別々に処理を書かなければならないことです。

クライアント側に載せた非同期データ取得処理が、そのままサーバ側で動いてくれればと思ったことはありませんか?先ほど「Next.js の初期レンダリングは非同期に対応していなかった」と書きました。つまり過去形です。実は現在の Next.js(13 系)は React18 対応と共に非同期レンダリングが可能になっています。

Next.js の非同期レンダリング

Next.js で非同期レンダリングの基本はthrow promiseです。これによって非同期データが解決されるまで、レンダリングをやり直すことが出来ます。ちなみに SSR の初期レンダリング中にthrow promiseコンポーネントをSuspenseで囲むと SSR-Streaming になってしまい、特殊な HTML+JavaScript 必須コードが出力されてしまうので、今回は使いません。実はこの二つはセットで使うことが必須の機能ではないのです。

サンプル

内容

GraphQL で日付データを取得し、その内容を SSR で出力しています。

動作確認用 URL

https://next-urql.vercel.app/

動作画面

単純に日付が表示されているだけのように見えますが、SSR で初期 HTML の中に日付が含まれています。そのため、 JavaScript を切ってブラウザのリロードを行った場合も表示内容は日付が表示された状態となります。

{"width":"470px","height":"278px"}

以下が初期 HTML の内容です。日付表示用のタグと、サーバ側で生成した urql のキャッシュをクライアントに引き渡すためのデータが入っています。データの引き渡しは ServerComponents+ClientComponents の組み合わせを使うと自動生成されるのですが、その構成は不便な点が多すぎるため、今回作ったライブラリ側で引き渡し処理を生成しています。ということで ServerComponents は使っていません。

{"width":"1030px","height":"522px"}

今回作った機能をパッケージ化したもの

その過程で以下のパッケージを npm に登録しました。

https://www.npmjs.com/package/@react-libraries/next-exchange-ssr

コードの内容

  • src/pages/index.tsx

Upload の部分は別の記事で使っている部分なので気にしないでください。重要なのはuseQueryです。これ、何の変哲もありません。SSR を意識する必要は無く、完全にいつも通りです。Next.js+urql の組み合わせでよく使われるwithUrqlClientもいりません。

1import { gql, useMutation, useQuery } from "urql";
2
3// Date retrieval
4const QUERY = gql`
5 query date {
6 date
7 }
8`;
9
10// Uploading files
11const UPLOAD = gql`
12 mutation Upload($file: Upload!) {
13 upload(file: $file) {
14 name
15 type
16 value
17 }
18 }
19`;
20
21const Page = () => {
22 const [{ data }, refetch] = useQuery({ query: QUERY });
23 const [{ data: file }, upload] = useMutation(UPLOAD);
24
25 return (
26 <>
27 <a
28 target="_blank"
29 href="https://github.com/SoraKumo001/next-apollo-server"
30 rel="noreferrer"
31 >
32 Source code
33 </a>
34 <hr />
35 {/* SSRedacted data can be updated by refetch. */}
36 <button onClick={() => refetch({ requestPolicy: "network-only" })}>
37 Update date
38 </button> {/* Dates are output as SSR. */}
39 {data?.date &&
40 new Date(data.date).toLocaleString("en-US", { timeZone: "UTC" })}
41 {/* File upload sample from here down. */}
42 <div
43 style={{
44 height: "100px",
45 width: "100px",
46 background: "lightgray",
47 marginTop: "8px",
48 padding: "8px",
49 }}
50 onDragOver={(e) => {
51 e.preventDefault();
52 }}
53 onDrop={(e) => {
54 const file = e.dataTransfer.files[0];
55 if (file) {
56 upload({ file });
57 }
58 e.preventDefault();
59 }}
60 >
61 Upload Area
62 </div>
63 {/* Display of information on returned file data to check upload operation. */}
64 {file && <pre>{JSON.stringify(file, undefined, " ")}</pre>}
65 </>
66 );
67};
68
69export default Page;

  • src/pages/_app.tsx

ファイルアップロード用にmultipartFetchExchangeとか使ってますが、その部分は気にしないでください。

今回重要なのはcreateNextSSRExchangeNextSSRProviderです。createNextSSRExchangeは、urql 標準の ssrExchange を拡張したものです。throw promiseで発生したデータを初期レンダリングし、クライアントに持ち越せるようにしました。この Exchange を含めるだけで、魔法のようにコンポーネントに載せた hook が SSR の対象になります。

サーバで取得したデータをクライアントに持ち越すためにNextSSRProviderも必要になります。これを入れ無くても SSR 自体は行えるのですが、クライアント側のレンダリングで urql の初期キャッシュが空状態で始まってしまうので、データを fetch する処理が動いてしまいます。

また、getInitialProps自体は使っていないのですが、これを入れておかないと_app.tsx がビルド時に静的に作られてクライアントアクセス時に再実行されなくなるので、最適化防止のために必要になります。

1import {
2 createNextSSRExchange,
3 NextSSRProvider,
4} from "@react-libraries/next-exchange-ssr";
5import { multipartFetchExchange } from "@urql/exchange-multipart-fetch";
6import { useMemo, useState } from "react";
7import { cacheExchange, Client, Provider } from "urql";
8import type { AppType } from "next/app";
9
10const isServerSide = typeof window === "undefined";
11const endpoint = "/api/graphql";
12const url = isServerSide
13 ? `${
14 process.env.VERCEL_URL
15 ? `https://${process.env.VERCEL_URL}`
16 : "http://localhost:3000"
17 }${endpoint}`
18 : endpoint;
19
20const App: AppType = ({ Component, pageProps }) => {
21 // NextSSRExchange to be unique on AppTree
22 const [nextSSRExchange] = useState(createNextSSRExchange);
23
24 const client = useMemo(() => {
25 return new Client({
26 url,
27 fetchOptions: {
28 headers: {
29 //// Required for `Upload`.
30 "apollo-require-preflight": "true",
31 //// When authenticating, the useMemo callback is re-executed and the cache is destroyed.
32 //'authorization': `Bearer ${token}`
33 },
34 },
35 // Only on the Server side do 'throw promise'.
36 suspense: isServerSide,
37 exchanges: [cacheExchange, nextSSRExchange, multipartFetchExchange],
38 });
39 }, [nextSSRExchange /*,token*/]);
40
41 return (
42 <Provider value={client}>
43 {/* Additional data collection functions for SSR */}
44 <NextSSRProvider>
45 <Component {...pageProps} />
46 </NextSSRProvider>
47 </Provider>
48 );
49};
50
51// Create getInitialProps that do nothing to prevent Next.js optimisation.
52App.getInitialProps = () => ({});
53
54export default App;

  • @urql/exchange-multipart-fetch

こちらは、今回作った Exchange をパッケージ化したものです。
SSR 用 Exchange の中身は以下のようになります。
throw promiseの待機処理
・収集したデータを HTML に出力する処理
・クライアント側でキャッシュに載せる処理

1import { DocumentNode } from "graphql";
2import { createElement, Fragment, ReactNode } from "react";
3import {
4 AnyVariables,
5 composeExchanges,
6 Exchange,
7 makeResult,
8 OperationResult,
9 ssrExchange,
10 TypedDocumentNode,
11 useClient,
12} from "urql";
13
14import { pipe, tap, filter, merge, mergeMap, fromPromise } from "wonka";
15
16type Promises = Set<Promise<void>>;
17const DATA_NAME = "__NEXT_DATA_PROMISE__";
18const isServerSide = typeof window === "undefined";
19
20/**
21 * Collecting data from HTML
22 */
23export const getInitialState = () => {
24 if (typeof window !== "undefined") {
25 const node = document.getElementById(DATA_NAME);
26 if (node) return JSON.parse(node.innerHTML);
27 }
28 return undefined;
29};
30
31/**
32 * Wait until end of Query and output collected data at render time
33 */
34const DataRender = () => {
35 const client = useClient();
36 if (isServerSide) {
37 const extractData = client.readQuery(`query{extractData}`, {})?.data
38 .extractData;
39 if (!extractData) {
40 throw client.query(`query{extractData}`, {}).toPromise();
41 }
42 return createElement("script", {
43 id: DATA_NAME,
44 type: "application/json",
45 dangerouslySetInnerHTML: { __html: JSON.stringify(extractData) },
46 });
47 }
48 return null;
49};
50
51/**
52 * For SSR data insertion
53 */
54export const NextSSRProvider = ({ children }: { children: ReactNode }) => {
55 return createElement(Fragment, {}, children, createElement(DataRender));
56};
57
58/**
59 * Get name from first field
60 */
61const getFieldSelectionName = (
62 query: DocumentNode | TypedDocumentNode<any, AnyVariables>
63) => {
64 const definition = query.definitions[0];
65 if (definition?.kind === "OperationDefinition") {
66 const selection = definition.selectionSet.selections[0];
67 if (selection?.kind === "Field") {
68 return selection.name.value;
69 }
70 }
71 return undefined;
72};
73
74/**
75 * local query function
76 */
77const createLocalValueExchange = <T extends object>(
78 key: string,
79 callback: () => Promise<T>
80) => {
81 const localValueExchange: Exchange = ({ forward }) => {
82 return (ops$) => {
83 const filterOps$ = pipe(
84 ops$,
85 filter(({ query }) => {
86 const selectionName = getFieldSelectionName(query);
87 return key !== selectionName;
88 }),
89 forward
90 );
91 const valueOps$ = pipe(
92 ops$,
93 mergeMap((op) => {
94 return fromPromise(
95 new Promise<OperationResult>(async (resolve) => {
96 resolve(makeResult(op, { data: { [key]: await callback() } }));
97 })
98 );
99 })
100 );
101 return merge([filterOps$, valueOps$]);
102 };
103 };
104 return localValueExchange;
105};
106
107/**
108 * Query standby extensions
109 */
110export const createNextSSRExchange = () => {
111 const promises: Promises = new Set();
112
113 const _ssrExchange = ssrExchange({
114 isClient: !isServerSide,
115 // Set up initial data required for SSR
116 initialState: getInitialState(),
117 });
118 const _nextExchange: Exchange = ({ forward }) => {
119 return (ops$) => {
120 if (!isServerSide) {
121 return forward(ops$);
122 } else {
123 return pipe(
124 ops$,
125 tap(({ kind, context }) => {
126 if (kind === "query") {
127 const promise = new Promise<void>((resolve) => {
128 context.resolve = resolve;
129 });
130 promises.add(promise);
131 }
132 }),
133 forward,
134 tap(({ operation }) => {
135 if (operation.kind === "query") {
136 operation.context.resolve();
137 }
138 })
139 );
140 }
141 };
142 };
143 return composeExchanges(
144 [
145 _ssrExchange,
146 isServerSide &&
147 createLocalValueExchange("extractData", async () => {
148 let length: number;
149 while ((length = promises?.size)) {
150 await Promise.allSettled(promises).then(() => {
151 if (length === promises.size) {
152 promises.clear();
153 }
154 });
155 }
156 return _ssrExchange.extractData();
157 }),
158 _nextExchange,
159 ].filter((v): v is Exchange => v !== false)
160 );
161};

まとめ

throw promiseSuspenseとセットにしたり、ServerComponentsSSR Streamingで使われるものという認識が広まっています。しかし通常の SSR を行う場合であっても、利便性の高い非同期データ待ち機能として使えます。データを取得する処理をクライアント・サーバ用に二重に書く必要がなく、2 パスレンダリングも必要ありません。コンポーネント上にいつも通りクエリを配置すれば、SSR 時の処理とクライアントの処理を同時かつ自然に書けるようになるのでとても便利です。