@apollo/client@3.8のuseSuspenseQueryでSSRを行う
@apollo/client@3.8で実装されたuseSuspenseQuery
@apollo/client@3.8ではuseSuspenseQueryが実装され、データの取得が完了するまでコンポーネントのレンダリングの一時停止が可能になりました。この機能によってコンポーネント上のfetch処理をサーバ上で待つことができるのでSSRが容易になります。データ取得専用の別コードをコンポーネント外に書く必要がなくなります。
SSRを行う上で必要なこと
SSR自体はuseSuspenseQueryを使った時点で初期HTMLにデータが入った状態でレンダリングできます。しかしServerComponentsを使った場合以外は、クライアント側でデータが消失します。サーバ上で取得したデータは、あくまでもHTMLという形でクライアントに送られるからです。このため、クライアント側の@apollo/clientのキャッシュは空の状態となり、再度データを取得しようとします。これではSSRの意味がありません。
サーバで取得したデータを@apollo/clientのキャッシュに渡す
SSR時に取得したデータをクライアント側に渡すには、初期レンダリング時のHTMLに、クライアント側で解釈可能なデータを混ぜて送る必要があります。@apollo/clientのキャッシュデータをJSON文字列として出力するだけなので、それほど難しいことではありません。
サンプルプログラム
GraphQLでアニメーションリストを取得するサンプルです。初期表示やリロード時はSSRでデータ込みでレンダリングされ、ページ切替時はクライアント側でfetchが走ります。
表示画面
初期レンダリング時のレスポンス
初期HTMLにデータが含まれており、クライアント側ではfetchしていません
ページ切替時の追加fetch
追加で必要になったデータのみfetchされます。@apollo/clientのキャッシュに積まれるので、読み込み済みのページに戻った場合は再fetchされません。
ソースコード
src/pages/_app.tsx
_app.tsxに@apollo/clientを動かすための<ApolloProvider>を設置します。そしてSSR時にクライアントにデータを受け渡すため、 <ApolloSSRProvider>を直下に配置します。基本、これだけです。複雑な初期化処理は必要ありません。
1import type { AppType } from 'next/app';2import { useState } from 'react';3import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client';4import { ApolloSSRProvider } from '@react-libraries/apollo-ssr';56const uri = 'https://graphql.anilist.co';78const App: AppType = ({ Component }) => {9 const [client] = useState(10 () =>11 new ApolloClient({12 uri,13 cache: new InMemoryCache({}),14 })15 );16 return (17 <ApolloProvider client={client}>18 {/* ←Add this */}19 <ApolloSSRProvider>20 <Component />21 </ApolloSSRProvider>22 </ApolloProvider>23 );24};2526// getInitialProps itself is not needed, but it is needed to prevent optimization of _app.tsx27// If you don't include this, it will be executed at build time and will not be called after that.28App.getInitialProps = () => ({});2930export default App;
src/pages/index.tsx
useSuspenseQueryでアニメーションリストを取得しています。サーバ専用、クライアント専用という処理は特にありません。そのままコンポーネント上でデータを取得します。
1import { gql, useApolloClient, useSuspenseQuery } from '@apollo/client';2import Link from 'next/link';3import { useRouter } from 'next/router';4import { Suspense } from 'react';56// Retrieving the animation list7const QUERY = gql`8 query Query($page: Int, $perPage: Int) {9 Page(page: $page, perPage: $perPage) {10 media {11 id12 title {13 english14 native15 }16 }17 pageInfo {18 currentPage19 hasNextPage20 lastPage21 perPage22 total23 }24 }25 }26`;2728type PageData = {29 Page: {30 media: { id: number; siteUrl: string; title: { english: string; native: string } }[];31 pageInfo: {32 currentPage: number;33 hasNextPage: boolean;34 lastPage: number;35 perPage: number;36 total: number;37 };38 };39};4041const AnimationList = ({ page }: { page: number }) => {42 const client = useApolloClient();43 const { data, refetch } = useSuspenseQuery<PageData>(QUERY, {44 variables: { page, perPage: 10 },45 });46 const { currentPage, lastPage } = data.Page.pageInfo;47 return (48 <>49 <button onClick={() => refetch()}>Refetch</button>50 <button onClick={() => client.resetStore()}>Reset</button>51 <div>52 <Link href={`/?page=${currentPage - 1}`}>53 <button disabled={currentPage <= 1}>←</button>54 </Link>55 <Link href={`/?page=${currentPage + 1}`}>56 <button disabled={currentPage >= lastPage}>→</button>57 </Link>58 {currentPage}/{lastPage}59 </div>60 {data.Page.media.map((v) => (61 <div62 key={v.id}63 style={{ border: 'solid 1px', padding: '8px', margin: '8px', borderRadius: '4px' }}64 >65 <div>66 {v.title.english} / {v.title.native}67 </div>68 <a href={v.siteUrl}>{v.siteUrl}</a>69 </div>70 ))}71 </>72 );73};7475const Page = () => {76 const router = useRouter();77 const page = Number(router.query.page) || 1;7879 return (80 <Suspense fallback={<div>Loading</div>}>81 <AnimationList page={page} />82 </Suspense>83 );84};8586export default Page;
ApolloSSRProviderの解説
https://github.com/SoraKumo001/next-apollo-ssr
@apollo/clientのSuspenseCacheに割り込んで、promiseのインスタンスを収集しています。各promiseの内容が解決されるまでPromise.allSettledで待って、キャッシュをJSONに変換してHTMLの中に出力します。
そしてクライアント側でそのJSONデータを受け取って、クライアント側の@apollo/clientに投入します。ServerComponents+ClientComponentsならコンポーネント間のデータの受け渡しを自動でやってくれるのですが、通常コンポーネントの場合はこのように処理する必要があります。ちなみにSuspenseCacheがパッケージの構造上ESMでしかアクセスできない位置にあったので、今回のパッケージはESMで作りました。Next.jsで使う分には特に問題ないと思われます。
index.mts
1import { useApolloClient } from "@apollo/client/index.js";2import { getSuspenseCache } from "@apollo/client/react/cache/getSuspenseCache.js";3import { SuspenseCache } from "@apollo/client/react/cache/SuspenseCache.js";4import { Fragment, ReactNode, createElement, useRef } from "react";5import type { ApolloClient, ObservableQuery } from "@apollo/client";6import type { SuspenseCacheOptions } from "@apollo/client/react/cache";7import type { InternalQueryReference } from "@apollo/client/react/cache/QueryReference";8import type { CacheKey } from "@apollo/client/react/cache/types";910class SSRCache extends SuspenseCache {11 constructor(options: SuspenseCacheOptions = Object.create(null)) {12 super(options);13 }14 SuspenseCache() {}15 getQueryRef<TData = unknown>(16 cacheKey: CacheKey,17 createObservable: () => ObservableQuery<TData>,18 ) {19 const ref = super.getQueryRef(cacheKey, createObservable);20 this.refs.add(ref as InternalQueryReference<unknown>);21 return ref;22 }2324 finished = false;25 refs = new Set<InternalQueryReference<unknown>>();26}2728const DATA_NAME = "__NEXT_DATA_PROMISE__";29const suspenseCacheSymbol = Symbol.for("apollo.suspenseCache");3031const DataRender = () => {32 const client = useApolloClient();33 const cache = getSuspenseCache(client);34 if (typeof window === "undefined") {35 if (!(cache instanceof SSRCache)) {36 throw new Error("SSRCache missing.");37 }38 if (!cache.finished) {39 throw Promise.allSettled(40 Array.from(cache.refs.values()).map(({ promise }) => promise),41 ).then((v) => {42 cache.finished = true;43 return v;44 });45 }46 }47 return createElement("script", {48 id: DATA_NAME,49 type: "application/json",50 dangerouslySetInnerHTML: {51 __html: JSON.stringify(client.extract()).replace(/</g, "\\u003c"),52 },53 });54};5556const useApolloCache = <T,>(57 client: ApolloClient<T> & {58 [suspenseCacheSymbol]?: SuspenseCache;59 },60) => {61 const property = useRef<{ initialized?: boolean }>({}).current;62 if (typeof window !== "undefined") {63 if (!property.initialized) {64 const node = document.getElementById(DATA_NAME);65 if (node) client.restore(JSON.parse(node.innerHTML));66 property.initialized = true;67 }68 } else {69 if (!client[suspenseCacheSymbol]) {70 client[suspenseCacheSymbol] = new SSRCache(71 client.defaultOptions.react?.suspense,72 );73 }74 }75};7677export const ApolloSSRProvider = ({ children }: { children: ReactNode }) => {78 const client = useApolloClient();79 useApolloCache(client);80 return createElement(Fragment, {}, children, createElement(DataRender));81};
まとめ
@apollo/client@3.8のRC時代にも同じようなものを作ったのですが、構造が大幅に変わったため、こちらもかなり修正が必要になりました。
現時点でのuseSuspenseQuery周りのソースコードを読む限り、この判定絶対にtrueにならないだろうとか、おかしな部分がチョクチョクあるので、もう少し熟れないとぶっちゃけ実用するのは厳しいと思います。GraphQLでthrow promiseを使うなら、urqlをオススメしておきます。https://www.npmjs.com/package/@react-libraries/next-exchange-ssrを使うと、urqlでSSRが可能になります。
ちなみにこのサイトはurql+next-exchange-ssrで使って作っています。