🐥

[Next.js] urql で GraphQL のデータをコンポーネント上の hook のみで SSR

2023/01/10に公開

Next.js の SSR は面倒くさい

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

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

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

実は可能になっていたコンポーネント側からの SSR データ作成

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

throw promiseSuspenseの関係

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

サンプル

内容

GraphQL で日付データを取得し、その内容を SSR で出力しています。この後のソースコードを見てもらえれば分かりますが、コンポーネント内のuseQueryは、クライアント専用に書く場合と同じ書き方です。

動作確認用 URL

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

動作画面

単純に日付が表示されているだけのように見えますが、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もいりません。

import { gql, useMutation, useQuery } from "urql";

// Date retrieval
const QUERY = gql`
  query date {
    date
  }
`;

// Uploading files
const UPLOAD = gql`
  mutation Upload($file: Upload!) {
    upload(file: $file) {
      name
      type
      value
    }
  }
`;

const Page = () => {
  const [{ data }, refetch] = useQuery({ query: QUERY });
  const [{ data: file }, upload] = useMutation(UPLOAD);

  return (
    <>
      <a
        target="_blank"
        href="https://github.com/SoraKumo001/next-apollo-server"
        rel="noreferrer"
      >
        Source code
      </a>
      <hr />
      {/* SSRedacted data can be updated by refetch. */}
      <button onClick={() => refetch({ requestPolicy: "network-only" })}>
        Update date
      </button> {/* Dates are output as SSR. */}
      {data?.date &&
        new Date(data.date).toLocaleString("en-US", { timeZone: "UTC" })}
      {/* File upload sample from here down. */}
      <div
        style={{
          height: "100px",
          width: "100px",
          background: "lightgray",
          marginTop: "8px",
          padding: "8px",
        }}
        onDragOver={(e) => {
          e.preventDefault();
        }}
        onDrop={(e) => {
          const file = e.dataTransfer.files[0];
          if (file) {
            upload({ file });
          }
          e.preventDefault();
        }}
      >
        Upload Area
      </div>
      {/* Display of information on returned file data to check upload operation. */}
      {file && <pre>{JSON.stringify(file, undefined, "  ")}</pre>}
    </>
  );
};

export default Page;
  • src/pages/_app.tsx

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

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

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

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

import {
  createNextSSRExchange,
  NextSSRProvider,
} from "@react-libraries/next-exchange-ssr";
import { multipartFetchExchange } from "@urql/exchange-multipart-fetch";
import { useMemo, useState } from "react";
import { cacheExchange, Client, Provider } from "urql";
import type { AppType } from "next/app";

const isServerSide = typeof window === "undefined";
const endpoint = "/api/graphql";
const url = isServerSide
  ? `${
      process.env.VERCEL_URL
        ? `https://${process.env.VERCEL_URL}`
        : "http://localhost:3000"
    }${endpoint}`
  : endpoint;

const App: AppType = ({ Component, pageProps }) => {
  // NextSSRExchange to be unique on AppTree
  const [nextSSRExchange] = useState(createNextSSRExchange);

  const client = useMemo(() => {
    return new Client({
      url,
      fetchOptions: {
        headers: {
          //// Required for `Upload`.
          "apollo-require-preflight": "true",
          //// When authenticating, the useMemo callback is re-executed and the cache is destroyed.
          //'authorization': `Bearer ${token}`
        },
      },
      // Only on the Server side do 'throw promise'.
      suspense: isServerSide,
      exchanges: [cacheExchange, nextSSRExchange, multipartFetchExchange],
    });
  }, [nextSSRExchange /*,token*/]);

  return (
    <Provider value={client}>
      {/* Additional data collection functions for SSR */}
      <NextSSRProvider>
        <Component {...pageProps} />
      </NextSSRProvider>
    </Provider>
  );
};

// Create getInitialProps that do nothing to prevent Next.js optimisation.
App.getInitialProps = () => ({});

export default App;
  • @urql/exchange-multipart-fetch

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

import { DocumentNode } from "graphql";
import { createElement, Fragment, ReactNode } from "react";
import {
  AnyVariables,
  composeExchanges,
  Exchange,
  makeResult,
  OperationResult,
  ssrExchange,
  TypedDocumentNode,
  useClient,
} from "urql";

import { pipe, tap, filter, merge, mergeMap, fromPromise } from "wonka";

type Promises = Set<Promise<void>>;
const DATA_NAME = "__NEXT_DATA_PROMISE__";
const isServerSide = typeof window === "undefined";

/**
 * Collecting data from HTML
 */
export const getInitialState = () => {
  if (typeof window !== "undefined") {
    const node = document.getElementById(DATA_NAME);
    if (node) return JSON.parse(node.innerHTML);
  }
  return undefined;
};

/**
 * Wait until end of Query and output collected data at render time
 */
const DataRender = () => {
  const client = useClient();
  if (isServerSide) {
    const extractData = client.readQuery(`query{extractData}`, {})?.data
      .extractData;
    if (!extractData) {
      throw client.query(`query{extractData}`, {}).toPromise();
    }
    return createElement("script", {
      id: DATA_NAME,
      type: "application/json",
      dangerouslySetInnerHTML: { __html: JSON.stringify(extractData) },
    });
  }
  return null;
};

/**
 * For SSR data insertion
 */
export const NextSSRProvider = ({ children }: { children: ReactNode }) => {
  return createElement(Fragment, {}, children, createElement(DataRender));
};

/**
 * Get name from first field
 */
const getFieldSelectionName = (
  query: DocumentNode | TypedDocumentNode<any, AnyVariables>
) => {
  const definition = query.definitions[0];
  if (definition?.kind === "OperationDefinition") {
    const selection = definition.selectionSet.selections[0];
    if (selection?.kind === "Field") {
      return selection.name.value;
    }
  }
  return undefined;
};

/**
 * local query function
 */
const createLocalValueExchange = <T extends object>(
  key: string,
  callback: () => Promise<T>
) => {
  const localValueExchange: Exchange = ({ forward }) => {
    return (ops$) => {
      const filterOps$ = pipe(
        ops$,
        filter(({ query }) => {
          const selectionName = getFieldSelectionName(query);
          return key !== selectionName;
        }),
        forward
      );
      const valueOps$ = pipe(
        ops$,
        mergeMap((op) => {
          return fromPromise(
            new Promise<OperationResult>(async (resolve) => {
              resolve(makeResult(op, { data: { [key]: await callback() } }));
            })
          );
        })
      );
      return merge([filterOps$, valueOps$]);
    };
  };
  return localValueExchange;
};

/**
 * Query standby extensions
 */
export const createNextSSRExchange = () => {
  const promises: Promises = new Set();

  const _ssrExchange = ssrExchange({
    isClient: !isServerSide,
    // Set up initial data required for SSR
    initialState: getInitialState(),
  });
  const _nextExchange: Exchange = ({ forward }) => {
    return (ops$) => {
      if (!isServerSide) {
        return forward(ops$);
      } else {
        return pipe(
          ops$,
          tap(({ kind, context }) => {
            if (kind === "query") {
              const promise = new Promise<void>((resolve) => {
                context.resolve = resolve;
              });
              promises.add(promise);
            }
          }),
          forward,
          tap(({ operation }) => {
            if (operation.kind === "query") {
              operation.context.resolve();
            }
          })
        );
      }
    };
  };
  return composeExchanges(
    [
      _ssrExchange,
      isServerSide &&
        createLocalValueExchange("extractData", async () => {
          let length: number;
          while ((length = promises?.size)) {
            await Promise.allSettled(promises).then(() => {
              if (length === promises.size) {
                promises.clear();
              }
            });
          }
          return _ssrExchange.extractData();
        }),
      _nextExchange,
    ].filter((v): v is Exchange => v !== false)
  );
};

まとめ

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

GitHubで編集を提案

Discussion