空雲 Blog

Eye catch@apollo/[email protected]のuseSuspenseQueryでSSRを行う

publication: 2023/08/16
update:2024/02/23

@apollo/[email protected]で実装されたuseSuspenseQuery

@apollo/[email protected]ではuseSuspenseQueryが実装され、データの取得が完了するまでコンポーネントのレンダリングの一時停止が可能になりました。この機能によってコンポーネント上のfetch処理をサーバ上で待つことができるのでSSRが容易になります。データ取得専用の別コードをコンポーネント外に書く必要がなくなります。

SSRを行う上で必要なこと

SSR自体はuseSuspenseQueryを使った時点で初期HTMLにデータが入った状態でレンダリングできます。しかしServerComponentsを使った場合以外は、クライアント側でデータが消失します。サーバ上で取得したデータは、あくまでもHTMLという形でクライアントに送られるからです。このため、クライアント側の@apollo/clientのキャッシュは空の状態となり、再度データを取得しようとします。これではSSRの意味がありません。

サーバで取得したデータを@apollo/clientのキャッシュに渡す

SSR時に取得したデータをクライアント側に渡すには、初期レンダリング時のHTMLに、クライアント側で解釈可能なデータを混ぜて送る必要があります。@apollo/clientのキャッシュデータをJSON文字列として出力するだけなので、それほど難しいことではありません。

サンプルプログラム

GraphQLでアニメーションリストを取得するサンプルです。初期表示やリロード時はSSRでデータ込みでレンダリングされ、ページ切替時はクライアント側でfetchが走ります。

{"width":"1015px","height":"712px"}

  • 初期レンダリング時のレスポンス

初期HTMLにデータが含まれており、クライアント側ではfetchしていません

{"width":"1019px","height":"727px"}

  • ページ切替時の追加fetch

追加で必要になったデータのみfetchされます。@apollo/clientのキャッシュに積まれるので、読み込み済みのページに戻った場合は再fetchされません。

{"width":"1024px","height":"183px"}

ソースコード

src/pages/_app.tsx

_app.tsxに@apollo/clientを動かすための<ApolloProvider>を設置します。そしてSSR時にクライアントにデータを受け渡すため、 <ApolloSSRProvider>を直下に配置します。基本、これだけです。複雑な初期化処理は必要ありません。

import type { AppType } from 'next/app'; import { useState } from 'react'; import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client'; import { ApolloSSRProvider } from '@react-libraries/apollo-ssr'; const uri = 'https://graphql.anilist.co'; const App: AppType = ({ Component }) => { const [client] = useState( () => new ApolloClient({ uri, cache: new InMemoryCache({}), }) ); return ( <ApolloProvider client={client}> {/* ←Add this */} <ApolloSSRProvider> <Component /> </ApolloSSRProvider> </ApolloProvider> ); }; // getInitialProps itself is not needed, but it is needed to prevent optimization of _app.tsx // If you don't include this, it will be executed at build time and will not be called after that. App.getInitialProps = () => ({}); export default App;

src/pages/index.tsx

useSuspenseQueryでアニメーションリストを取得しています。サーバ専用、クライアント専用という処理は特にありません。そのままコンポーネント上でデータを取得します。

import { gql, useApolloClient, useSuspenseQuery } from '@apollo/client'; import Link from 'next/link'; import { useRouter } from 'next/router'; import { Suspense } from 'react'; // Retrieving the animation list const QUERY = gql` query Query($page: Int, $perPage: Int) { Page(page: $page, perPage: $perPage) { media { id title { english native } } pageInfo { currentPage hasNextPage lastPage perPage total } } } `; type PageData = { Page: { media: { id: number; siteUrl: string; title: { english: string; native: string } }[]; pageInfo: { currentPage: number; hasNextPage: boolean; lastPage: number; perPage: number; total: number; }; }; }; const AnimationList = ({ page }: { page: number }) => { const client = useApolloClient(); const { data, refetch } = useSuspenseQuery<PageData>(QUERY, { variables: { page, perPage: 10 }, }); const { currentPage, lastPage } = data.Page.pageInfo; return ( <> <button onClick={() => refetch()}>Refetch</button> <button onClick={() => client.resetStore()}>Reset</button> <div> <Link href={`/?page=${currentPage - 1}`}> <button disabled={currentPage <= 1}>←</button> </Link> <Link href={`/?page=${currentPage + 1}`}> <button disabled={currentPage >= lastPage}>→</button> </Link> {currentPage}/{lastPage} </div> {data.Page.media.map((v) => ( <div key={v.id} style={{ border: 'solid 1px', padding: '8px', margin: '8px', borderRadius: '4px' }} > <div> {v.title.english} / {v.title.native} </div> <a href={v.siteUrl}>{v.siteUrl}</a> </div> ))} </> ); }; const Page = () => { const router = useRouter(); const page = Number(router.query.page) || 1; return ( <Suspense fallback={<div>Loading</div>}> <AnimationList page={page} /> </Suspense> ); }; export default Page;

ApolloSSRProviderの解説

https://github.com/SoraKumo001/next-apollo-ssr

@apollo/clientSuspenseCacheに割り込んで、promiseのインスタンスを収集しています。各promiseの内容が解決されるまでPromise.allSettledで待って、キャッシュをJSONに変換してHTMLの中に出力します。
そしてクライアント側でそのJSONデータを受け取って、クライアント側の@apollo/clientに投入します。ServerComponents+ClientComponentsならコンポーネント間のデータの受け渡しを自動でやってくれるのですが、通常コンポーネントの場合はこのように処理する必要があります。ちなみにSuspenseCacheがパッケージの構造上ESMでしかアクセスできない位置にあったので、今回のパッケージはESMで作りました。Next.jsで使う分には特に問題ないと思われます。

  • index.mts

import { useApolloClient } from "@apollo/client/index.js"; import { getSuspenseCache } from "@apollo/client/react/cache/getSuspenseCache.js"; import { SuspenseCache } from "@apollo/client/react/cache/SuspenseCache.js"; import { Fragment, ReactNode, createElement, useRef } from "react"; import type { ApolloClient, ObservableQuery } from "@apollo/client"; import type { SuspenseCacheOptions } from "@apollo/client/react/cache"; import type { InternalQueryReference } from "@apollo/client/react/cache/QueryReference"; import type { CacheKey } from "@apollo/client/react/cache/types"; class SSRCache extends SuspenseCache { constructor(options: SuspenseCacheOptions = Object.create(null)) { super(options); } SuspenseCache() {} getQueryRef<TData = unknown>( cacheKey: CacheKey, createObservable: () => ObservableQuery<TData>, ) { const ref = super.getQueryRef(cacheKey, createObservable); this.refs.add(ref as InternalQueryReference<unknown>); return ref; } finished = false; refs = new Set<InternalQueryReference<unknown>>(); } const DATA_NAME = "__NEXT_DATA_PROMISE__"; const suspenseCacheSymbol = Symbol.for("apollo.suspenseCache"); const DataRender = () => { const client = useApolloClient(); const cache = getSuspenseCache(client); if (typeof window === "undefined") { if (!(cache instanceof SSRCache)) { throw new Error("SSRCache missing."); } if (!cache.finished) { throw Promise.allSettled( Array.from(cache.refs.values()).map(({ promise }) => promise), ).then((v) => { cache.finished = true; return v; }); } } return createElement("script", { id: DATA_NAME, type: "application/json", dangerouslySetInnerHTML: { __html: JSON.stringify(client.extract()).replace(/</g, "\\u003c"), }, }); }; const useApolloCache = <T,>( client: ApolloClient<T> & { [suspenseCacheSymbol]?: SuspenseCache; }, ) => { const property = useRef<{ initialized?: boolean }>({}).current; if (typeof window !== "undefined") { if (!property.initialized) { const node = document.getElementById(DATA_NAME); if (node) client.restore(JSON.parse(node.innerHTML)); property.initialized = true; } } else { if (!client[suspenseCacheSymbol]) { client[suspenseCacheSymbol] = new SSRCache( client.defaultOptions.react?.suspense, ); } } }; export const ApolloSSRProvider = ({ children }: { children: ReactNode }) => { const client = useApolloClient(); useApolloCache(client); return createElement(Fragment, {}, children, createElement(DataRender)); };

まとめ

@apollo/[email protected]のRC時代にも同じようなものを作ったのですが、構造が大幅に変わったため、こちらもかなり修正が必要になりました。
現時点でのuseSuspenseQuery周りのソースコードを読む限り、この判定絶対にtrueにならないだろうとか、おかしな部分がチョクチョクあるので、もう少し熟れないとぶっちゃけ実用するのは厳しいと思います。GraphQLでthrow promiseを使うなら、urqlをオススメしておきます。https://www.npmjs.com/package/@react-libraries/next-exchange-ssrを使うと、urqlでSSRが可能になります。

ちなみにこのサイトはurql+next-exchange-ssrで使って作っています。