Next.js+urqlでGraphQLデータをコンポーネント上の hookのみでSSRさせる
Next.js の SSR は面倒くさい
SSR を考えない場合、外部から取ってきた非同期データのレンダリングは、コンポーネント内に取得処理を置いて、受け取れた時点でデータを表示という流れで比較的簡単に記述できます。しかし SSR で初期 HTML に非同期データを加えようと思うと、一気に面倒になります。
何故面倒なのかと言えば、Next.js の初期レンダリングは非同期に対応していなかったからです。そのため、一般的な方法で SSR をやろうとするとgetInitialPropsやgetServerSidePropsを使って非同期データを収集し、コンポーネントにデータを引き渡す流れになります。
この構成の問題点は、クライアントで再フェッチが必要になったときに、サーバ側とクライアント側で別々に処理を書かなければならないことです。
クライアント側に載せた非同期データ取得処理が、そのままサーバ側で動いてくれればと思ったことはありませんか?先ほど「Next.js の初期レンダリングは非同期に対応していなかった」と書きました。つまり過去形です。実は現在の Next.js(13 系)は React18 対応と共に非同期レンダリングが可能になっています。
Next.js の非同期レンダリング
Next.js で非同期レンダリングの基本はthrow promiseです。これによって非同期データが解決されるまで、レンダリングをやり直すことが出来ます。ちなみに SSR の初期レンダリング中にthrow promiseコンポーネントをSuspenseで囲むと SSR-Streaming になってしまい、特殊な HTML+JavaScript 必須コードが出力されてしまうので、今回は使いません。実はこの二つはセットで使うことが必須の機能ではないのです。
サンプル
内容
GraphQL で日付データを取得し、その内容を SSR で出力しています。
動作確認用 URL
動作画面
単純に日付が表示されているだけのように見えますが、SSR で初期 HTML の中に日付が含まれています。そのため、 JavaScript を切ってブラウザのリロードを行った場合も表示内容は日付が表示された状態となります。
以下が初期 HTML の内容です。日付表示用のタグと、サーバ側で生成した urql のキャッシュをクライアントに引き渡すためのデータが入っています。データの引き渡しは ServerComponents+ClientComponents の組み合わせを使うと自動生成されるのですが、その構成は不便な点が多すぎるため、今回作ったライブラリ側で引き渡し処理を生成しています。ということで ServerComponents は使っていません。
今回作った機能をパッケージ化したもの
その過程で以下のパッケージを 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";23// Date retrieval4const QUERY = gql`5 query date {6 date7 }8`;910// Uploading files11const UPLOAD = gql`12 mutation Upload($file: Upload!) {13 upload(file: $file) {14 name15 type16 value17 }18 }19`;2021const Page = () => {22 const [{ data }, refetch] = useQuery({ query: QUERY });23 const [{ data: file }, upload] = useMutation(UPLOAD);2425 return (26 <>27 <a28 target="_blank"29 href="https://github.com/SoraKumo001/next-apollo-server"30 rel="noreferrer"31 >32 Source code33 </a>34 <hr />35 {/* SSRedacted data can be updated by refetch. */}36 <button onClick={() => refetch({ requestPolicy: "network-only" })}>37 Update date38 </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 <div43 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 Area62 </div>63 {/* Display of information on returned file data to check upload operation. */}64 {file && <pre>{JSON.stringify(file, undefined, " ")}</pre>}65 </>66 );67};6869export default Page;
src/pages/_app.tsx
ファイルアップロード用にmultipartFetchExchangeとか使ってますが、その部分は気にしないでください。
今回重要なのはcreateNextSSRExchangeとNextSSRProviderです。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";910const isServerSide = typeof window === "undefined";11const endpoint = "/api/graphql";12const url = isServerSide13 ? `${14 process.env.VERCEL_URL15 ? `https://${process.env.VERCEL_URL}`16 : "http://localhost:3000"17 }${endpoint}`18 : endpoint;1920const App: AppType = ({ Component, pageProps }) => {21 // NextSSRExchange to be unique on AppTree22 const [nextSSRExchange] = useState(createNextSSRExchange);2324 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*/]);4041 return (42 <Provider value={client}>43 {/* Additional data collection functions for SSR */}44 <NextSSRProvider>45 <Component {...pageProps} />46 </NextSSRProvider>47 </Provider>48 );49};5051// Create getInitialProps that do nothing to prevent Next.js optimisation.52App.getInitialProps = () => ({});5354export 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";1314import { pipe, tap, filter, merge, mergeMap, fromPromise } from "wonka";1516type Promises = Set<Promise<void>>;17const DATA_NAME = "__NEXT_DATA_PROMISE__";18const isServerSide = typeof window === "undefined";1920/**21 * Collecting data from HTML22 */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};3031/**32 * Wait until end of Query and output collected data at render time33 */34const DataRender = () => {35 const client = useClient();36 if (isServerSide) {37 const extractData = client.readQuery(`query{extractData}`, {})?.data38 .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};5051/**52 * For SSR data insertion53 */54export const NextSSRProvider = ({ children }: { children: ReactNode }) => {55 return createElement(Fragment, {}, children, createElement(DataRender));56};5758/**59 * Get name from first field60 */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};7374/**75 * local query function76 */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 forward90 );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};106107/**108 * Query standby extensions109 */110export const createNextSSRExchange = () => {111 const promises: Promises = new Set();112113 const _ssrExchange = ssrExchange({114 isClient: !isServerSide,115 // Set up initial data required for SSR116 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 promiseはSuspenseとセットにしたり、ServerComponentsやSSR Streamingで使われるものという認識が広まっています。しかし通常の SSR を行う場合であっても、利便性の高い非同期データ待ち機能として使えます。データを取得する処理をクライアント・サーバ用に二重に書く必要がなく、2 パスレンダリングも必要ありません。コンポーネント上にいつも通りクエリを配置すれば、SSR 時の処理とクライアントの処理を同時かつ自然に書けるようになるのでとても便利です。