@apollo/[email protected]のuseSuspenseQueryでSSRを行う
@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が走ります。
表示画面
初期レンダリング時のレスポンス
初期HTMLにデータが含まれており、クライアント側ではfetchしていません
ページ切替時の追加fetch
追加で必要になったデータのみfetchされます。@apollo/clientのキャッシュに積まれるので、読み込み済みのページに戻った場合は再fetchされません。
ソースコード
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/clientのSuspenseCacheに割り込んで、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/cl[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で使って作っています。