空雲 Blog

Eye catch@apollo/client@3.8のuseSuspenseQueryでSSRを行う

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

@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が走ります。

{"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>を直下に配置します。基本、これだけです。複雑な初期化処理は必要ありません。

1import type { AppType } from 'next/app';
2import { useState } from 'react';
3import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client';
4import { ApolloSSRProvider } from '@react-libraries/apollo-ssr';
5
6const uri = 'https://graphql.anilist.co';
7
8const 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};
25
26// getInitialProps itself is not needed, but it is needed to prevent optimization of _app.tsx
27// If you don't include this, it will be executed at build time and will not be called after that.
28App.getInitialProps = () => ({});
29
30export 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';
5
6// Retrieving the animation list
7const QUERY = gql`
8 query Query($page: Int, $perPage: Int) {
9 Page(page: $page, perPage: $perPage) {
10 media {
11 id
12 title {
13 english
14 native
15 }
16 }
17 pageInfo {
18 currentPage
19 hasNextPage
20 lastPage
21 perPage
22 total
23 }
24 }
25 }
26`;
27
28type 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};
40
41const 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 <div
62 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};
74
75const Page = () => {
76 const router = useRouter();
77 const page = Number(router.query.page) || 1;
78
79 return (
80 <Suspense fallback={<div>Loading</div>}>
81 <AnimationList page={page} />
82 </Suspense>
83 );
84};
85
86export 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

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";
9
10class 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 }
23
24 finished = false;
25 refs = new Set<InternalQueryReference<unknown>>();
26}
27
28const DATA_NAME = "__NEXT_DATA_PROMISE__";
29const suspenseCacheSymbol = Symbol.for("apollo.suspenseCache");
30
31const 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};
55
56const 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};
76
77export 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で使って作っています。