はじめに
最近 Next.js ベースのプロジェクトに GraphQL クライアントとして Relay を導入したのですが、これが端的に言ってめちゃくちゃ大変だったので記事にしました。
チームの試行錯誤の結果を余すところなく伝えたいと張り切りすぎた結果死ぬほど長くなってしまったので、「いいからコードはよ」という方は 最終的にこんな実装になりました (コード編)をご覧ください。
謝辞
愛するチームメンバーのみんな。これまでの道のりで私を支え、愛してくれたみんながいなければ、この記事は完成できなかったと思う。
また、この記事が世に出せたのは、スーパーテクニカルアドバイザーである koichik さんの力があったからこそだ。
本記事に優れているところがあれば、それは全部 koichik さんのおかげだ。そうじゃないところはわたしのせいだ。
前提知識
Relay
Meta 製の GraphQL クライアントです。
Relay の売りは Render-as-you-fetch パターンとの相性の良さで、これを実現するための API が豊富に提供されています。
Render-as-you-fetch パターン
React が Suspense によって実現できるとしているデータフェッチのパターンのことです。
レンダリングと同時に (むしろレンダリングよりも先に) データフェッチを開始して、その結果を待たずにレンダリングを開始する というものです。
本家が定義したドキュメントを探してみたのですが、現在は React v17 のドキュメントにしか残っていないようです。
Render-as-you-fetch (for example, Relay with Suspense): Start fetching all the required data for the next screen as early as possible, and start rendering the new screen immediately — before we get a network response. As data streams in, React retries rendering components that still need data until they’re all ready.
Render-as-you-fetch パターンについては、以下の記事にとても詳しく書いてあるので是非読んでみてください。
Next.js x Relay はどこが難しいのか
1. クライアントサイドナビゲーション時に getServerSideProps()
を使うと、キャッシュを有効活用できない
前提として、 Relayはクライアントサイドのキャッシュを有効活用することでパフォーマンスの向上を図っています(これは Relayに限った話ではなく、 React Query や Apollo Client、 Urql といった最近のデータフェッチライブラリはみんなそうですが)。
一方、Next.js においてレンダリングより前にデータフェッチを行いたい(かつ、静的なページではない)ような場合、通常は getServerSideProps()
を使います。この getServerSideProps()
というのが実は Relay をはじめとしたデータフェッチライブラリ群と相性が悪く、クライアントサイドナビゲーション(以降「ナビゲーション」と表記)の際も常にサーバサイドで動作し、クライアントサイドにキャッシュがあってもお構いなしにデータフェッチを実行してしまうのです。そのうえサーバサイドのデータフェッチが完了しないとレンダリングが始まらないので、Render-as-you-fetch ではなくなってしまいます(このような動きは Fetch-then-render と呼びます)。
全く動作しないといったレベルの不具合ではありませんが、 Relay の長所を殺してしまっていると言わざるをえません。
2. シングルトンの Environment
を生成すると SSR 時に詰む
ちょっと複雑な話なので、はじめに Relay 式のデータフェッチ方法について説明します。
前述の通り Relay の売りは Render-as-you-fetch パターンとの相性の良さなので、データフェッチ用の API と レンダリング用の API を組み合わせて使うことが推奨されています。
要するに、こんな感じにデータフェッチ&レンダリングしてくださいねと言われているわけです。
- レンダリング前にデータフェッチ用の API(
useQueryLoader()
,loadQuery()
,fetchQuery()
などがある) を呼び出してクエリをプリロード- これらの API はデータフェッチの完了を待たずに return するため、データフェッチと並行してレンダリングが行われる
- レンダリングの際に
usePreloadedQuery()
を呼び出し、プリロードしたクエリの情報を取得- このとき、データフェッチが完了していなければ
usePreloadedQuery()
がPromise
をthrow
してレンダリングは中断(すなわちサスペンド)される - 完了していればそのデータを使ってレンダリングが遂行される
- このとき、データフェッチが完了していなければ
というのを踏まえてここからが本題ですが、上記のような動きはデータフェッチを行う場所とレンダリングを行う場所とで同一の Environment
(すなわち Relay のキャッシュ)にアクセスできなければ成立しません。
これが問題になるのは SSR やハイドレーションを行う場合で、ことハイドレーションにおいてはサーバサイドとクライアントサイドでネットワークを跨ぐため、同一の Environment
を参照することが物理的に不可能です。ところが Relay のドキュメントにはシングルトンの Environment
を作るようなサンプルしか載っていないため、自分たちでどうにか知恵を絞ってこれを解決する必要があるのです。
3. CSR only なページでは Render-as-you-fetch が実現できない
Render-as-you-fetch を実現する、すなわち「レンダリングの前にデータフェッチを開始して、結果を待たずにレンダリングを開始する」ためには、当然ですがデータフェッチを行うコードとレンダリングを行うコードが分離されている必要があります。
Next.js は、レンダリング開始前に呼び出される関数として getServerSideProps()
、 getStaticProps()
、 getStaticPaths()
等を提供していますが、これらはいずれも対象のページが CSR のみ である場合には呼ばれません。つまり、CSR 専用ページではレンダリング前にデータフェッチを開始する方法がないのです。
現在参画中のプロジェクトには CSR 専用ページがないため、これで困ることはありませんでした(したがってこの記事でも解決策の提示をしていません)が、この点も Next.js と Relay を組み合わせて使うことを困難にしている理由の一つだと思います。
解決策
上に挙げた問題に対して、以下のアプローチで解決を図りました。
※「3. CSR only なページでは Render-as-you-fetch が実現できない」件は除きます
1. getInitialProps()
を使う
getServerSideProps()
と getInitialProps()
の大きな違いはナビゲーション時の挙動で、getServerSideProps()
がサーバ上でしか動かないのに対し、getInitialProps()
は SSR 時にはサーバサイドで、ナビゲーション時にはクライアントサイドで動きます。
したがって、getInitialProps()
を使えば 「SSR 時にはサーバサイドでデータフェッチを実行」「ナビゲーション時にクライアントサイドに Relay のキャッシュがあるならそれを使う(サーバにリクエストを送らない)」といった処理が可能になるのです。
ただし、本記事執筆時点で getInitialProps()
は not recommended な API であり、将来 Nested Layout でサポートされるかも不明であることにご留意ください。正式に deprecated になった場合は代替手段を検討して記事をアップデートするつもりです。
2. あたかも同一の Environment
にアクセスしているかのような状態を自力で作る
ややこしいので、詳しくは次章でコードと一緒に解説します。
ざっくり言うと、 「getInitialProps()
が返す props
は App
コンポーネント経由でページコンポーネントに渡る」という Next.js の処理の流れを利用して頑張る作戦です。
最終的にこんな実装になりました(概要編)
SSR の場合とナビゲーションの場合とで処理を分けています。
SSR の場合
STEP1: サーバサイドで getInitialProps()
が呼び出される
-
fetchQuery()
をコールすることで、 Relay のキャッシュにデータ(InitialRecords
)が設定される - キャッシュから
initialRecords
を取り出し、これとクエリの元情報をprops
としてreturn
する- ここで返された
props
は、ページコンポーネントにそのまま渡るのではなく、pageProps
としてApp
コンポーネントに渡る(STEP2へ)
- ここで返された
STEP2: App
コンポーネントが呼び出される
- Relay の
Environment
を新たに作成する - その際、 STEP1 で
getInitialProps()
から返却されたprops
からInitialRecords
を取り出して、 Relay のキャッシュに設定する- 同様に、
props
から取り出したクエリの元情報からクエリのプリロードを行う(=preloadedQuery
を作成) -
preloadedQuery
をprops
としてreturn
する- ここで返された
props
がページコンポーネントに渡る(STEP3へ)
- ここで返された
- 同様に、
STEP3: ページコンポーネントが呼び出される
-
props
からpreloadedQuery
を取り出し、usePreloadedQuery()
に渡す - その戻り値を使ってレンダリング
STEP4: ハイドレーション
- STEP1 で返された
props
が SSR された HTML に埋め込まれ、今度はクライアントサイドのApp
コンポーネントに渡される - クライアントサイドで STEP2〜STEP3 が実行される
- SUCCESS!!!
ナビゲーションの場合
処理の流れは SSR と同じですが、やることはもっとシンプルです。
STEP1: クライアントサイドで getInitialProps()
が呼び出される
- 素直に
loadQuery()
でクエリをプリロードして、props
としてreturn
する
STEP2: App
コンポーネントが呼び出される
- 渡ってきた
props
をそのままページコンポーネントに受け流す
STEP3: ページコンポーネントが呼び出される
-
props
からpreloadedQuery
を取り出し、usePreloadedQuery()
に渡す - Render-as-you-fetch & SUCCESS!!
SSR もこのくらい簡単にできたらいいのに……
最終的にこんな実装になりました(コード編)
さて、前置きが長くなりすぎましたが、ここから実際のコードを見ていきます。
まずは型定義から
App
コンポーネントが受け取る pageProps
の型と、各ページコンポーネントが受け取る props
の型を定義します。
import { PreloadedQuery } from "react-relay";
import {
ConcreteRequest,
GraphQLTaggedNode,
OperationType,
Variables,
} from "relay-runtime";
import { RecordMap } from "relay-runtime/lib/store/RelayStoreTypes";
// 各ページコンポーネントが受け取る props の型
export type RelayPageProps<Q extends OperationType = OperationType> = {
initialPreloadedQuery?: PreloadedQuery<Q>;
};
// App コンポーネントが受け取る props の型
export type RelayAppPageProps<Q extends OperationType = OperationType> =
RelayPageProps<Q> & {
relayDehydratedState?: {
// Relay キャッシュから取り出したデータ
initialRecords: RecordMap;
// クエリの元情報
request: {
query: ConcreteRequest | GraphQLTaggedNode;
variables: Variables;
};
};
};
App
コンポーネント向けの RelayAppPageProps
型には Relay キャッシュから取り出したデータとクエリの元情報が入っています。
App
コンポーネントにはこの後、これらの情報を元にクエリのプリロードを行い、それを RelayPageProps
型の preloadedQuery
としてページコンポーネントへ受け渡してもらいます。
Environment
を作る
STEP2 で呼び出されるコードです(説明の都合上順番が前後してますが)。
上述の通り SSR では常に同一の Environment
にアクセスすることができないため、同一の Environment
にアクセスしているかのような状態を自力で作る必要があるのですが、それを行なっているのがこのコードです。
import { useState } from "react";
import { loadQuery } from "react-relay";
import { Environment, Network, RecordSource, Store } from "relay-runtime";
import fetchGraphQL from "./fetchGraphQL";
import { RelayAppPageProps, RelayPageProps } from "./types";
// SSRする場合に、シングルトンだと異なる利用者が同一の Environment (= キャッシュ) を参照してしまい事故が起きる
// これを防ぐため、サーバサイドでは毎回新しい Environment のインスタンスを作りつつ、クライアントサイドでは同じ Environment を参照できるようにする
function createRelayEnvironment() {
const network = Network.create(fetchGraphQL);
const store = new Store(new RecordSource());
const environment = new Environment({
network,
store,
isServer: typeof window === "undefined",
});
return environment;
}
let clientEnvironment: Environment | undefined;
// 呼び出し元がサーバなのかクライアントなのかを判断し、クライアントであれば同一のインスタンスを使い回す
export function getRelayEnvironment() {
if (typeof window === "undefined") {
return createRelayEnvironment();
}
clientEnvironment ??= createRelayEnvironment();
return clientEnvironment;
}
export function releaseRelayEnvironment() {
clientEnvironment = undefined;
}
// App の pageProps を加工してページコンポーネントの props に変換する関数
// getInitialProps から返却された props を元にしてクエリのプリロードを行い、initialPreloadedQuery としてページコンポーネントに返せるようにする
function transformRelayProps<AppPageProps extends RelayAppPageProps>(
environment: Environment,
pageProps: AppPageProps
): RelayPageProps {
// pageProps が relayDehydratedState を持たない場合は、pageProps をそのまま返す
// (※ナビゲーションで実行される場合には pageProps に relayDehydratedState フィールドを持たせないように実装する)
if (!pageProps.relayDehydratedState) {
return pageProps;
}
const {
relayDehydratedState: { initialRecords, request },
...otherProps
} = pageProps;
// Environment(すなわち Relay のキャッシュ)にデータをセットしている
environment.getStore().publish(new RecordSource(initialRecords));
const { query, variables } = request;
// props から受け取ったクエリの元情報から preloadedQuery を作成
const initialPreloadedQuery = loadQuery(environment, query, variables, {
fetchPolicy: "store-only",
});
return { ...otherProps, initialPreloadedQuery };
}
// App コンポーネントから呼び出される想定のフック
// Environment インスタンスと、ページコンポーネントに返すための props (プリロード済みのクエリの情報が入っている)をまとめて取得できる
export function useRelayEnvironment<AppPageProps extends RelayAppPageProps>(
pageProps: AppPageProps
) {
const [environment] = useState(getRelayEnvironment);
const transformedPageProps = transformRelayProps(environment, pageProps);
return { environment, transformedPageProps };
}
ところでこのコードにはちょっと問題があって、transformRelayProps()
の中で Relay の loadQuery()
を呼び出している(transformRelayProps()
が呼ばれた時点で既に App
コンポーネントのレンダリングが始まってしまっている)せいで、こんな警告が出てしまうのです。
Warning: Relay: `loadQuery` should not be called inside a React render function.
今は警告で済んでいるものの、ドキュメントによれば将来的にはエラーとなる可能性が示唆されています。
loadQuery() will throw an error if it is called during React's render phase.
回避策としては、 Relay チームが作った Next.js example のように、 loadQuery()
を使わずに preloadedQuery
相当のオブジェクトを作るやり方がありますが、これはこれで Relay の内部実装に依存した書き方であるという点でいまひとつです。
// TODO: create using a function exported from react-relay package
queryRefs[queryName] = {
environment,
fetchKey: params.id,
fetchPolicy: 'store-or-network',
isDisposed: false,
name: params.name,
kind: 'PreloadedQuery',
variables,
};
これら2通りのやり方を比較して検討した結果、我々のチームでは警告が出ることを承知の上で loadQuery()
を使うという判断をしました。TODO コメントにあるような API の提供を心待ちにしています。
各ページの getInitialProps()
から呼ばれて、いい感じにクエリのプリロードを行ってくれる関数を作る
STEP1 で呼び出されるコードです。
ナビゲーション時に呼ばれた場合は、シンプルに loadQuery()
でロードしたクエリをそのままページコンポーネントに渡します。
SSR の時だけ、キャッシュにデータをロードしたりそこから InitialRecords
を取得して App
コンポーネントに渡すための props
にセットしたりといった複雑な処理を行います。
import { loadQuery } from "react-relay";
import {
ConcreteRequest,
fetchQuery,
GraphQLTaggedNode,
OperationType,
} from "relay-runtime";
import { getRelayEnvironment } from "./environment";
import { RelayAppPageProps, RelayPageProps } from "./types";
// この関数はクライアントサイドで実行された場合とサーバサイドで実行された場合とで返すものが変わる
export async function preloadQuery<Query extends OperationType>(
query: ConcreteRequest | GraphQLTaggedNode,
variables: Query["variables"]
): Promise<RelayPageProps<Query> | RelayAppPageProps> {
const environment = getRelayEnvironment();
// クライアントサイドで実行されたら、loadQuery で preloadedQuery を取得してそれを return する
if (typeof window !== "undefined") {
const initialPreloadedQuery = loadQuery<Query>(
environment,
query,
variables
);
return { initialPreloadedQuery };
}
// サーバサイドで実行されたら fetchQuery を実行し、Relay のキャッシュにデータをロードする
await new Promise<void>((complete, error) => {
fetchQuery(environment, query, variables).subscribe({ complete, error });
});
// キャッシュにセットされたデータを InitialRecords として取得する
const initialRecords = environment.getStore().getSource().toJSON();
// App コンポーネント向けの pageProps として、 relayDehydratedState フィールドに initialRecords をセットする
const resultForApp: RelayAppPageProps = {
relayDehydratedState: {
initialRecords,
request: { query, variables },
},
};
return resultForApp;
}
ここでポイントになるのが、サーバサイドでのみ実行される以下のコードです。
await new Promise<void>((complete, error) => {
fetchQuery(environment, query, variables).subscribe({ complete, error });
});
ここでは Promise
を await
しており、その Promise
は fetchQuery()
によるデータフェッチが完了した時点で fulfilled
となります。すなわち、SSR 時は Render-as-you-fetch にならず、データフェッチが完全に完了してからレンダリングを開始する Fetch-then-render の挙動になるのです。
本記事執筆時点での Next.js(v12.3.1) は Streaming SSR を正式にサポートしていないので、ここで await
をつけないと意図通りに動きません。
いずれ Next.js が Streaming SSR に対応した暁には、この await
を削ることで SSR でも Render-as-you-fetch を実現できるようになるかもしれません。
クエリの破棄を行うヘルパー的なフックを作る
loadQuery()
によってプリロードされたクエリは、不要になったら自前で破棄する必要があります(Relay ストアのメモリリークを防ぐため)。
これを行うためのヘルパーが以下です。
import { useEffect, useRef } from "react";
import { PreloadedQuery } from "react-relay";
import { OperationType } from "relay-runtime";
export function useDispose<T extends OperationType>(
preloadedQuery: PreloadedQuery<T> | undefined | null
) {
const ref = useRef<Set<PreloadedQuery<T>>>();
if (!ref.current) {
ref.current = new Set<PreloadedQuery<T>>();
}
const disposingQueries = ref.current;
if (preloadedQuery != null) {
disposingQueries.delete(preloadedQuery);
}
useEffect(() => {
return () => {
if (preloadedQuery == null) {
return;
}
// productionモードでは Strict モードが無効になるので以降の処理は関係ない
if (process.env.NODE_ENV === "production") {
preloadedQuery.dispose();
return;
}
// React 18の Strict モードに対応するためのコード
disposingQueries.add(preloadedQuery);
setTimeout(() => {
if (
disposingQueries.delete(preloadedQuery) &&
!preloadedQuery.isDisposed &&
typeof preloadedQuery.dispose === "function"
) {
preloadedQuery.dispose();
}
}, 10000);
};
}, [preloadedQuery, disposingQueries]);
}
React 18 の Strict モードに対応するためのコードが入っているため少しややこしくなっていますが、やりたいことの本質はページコンポーネントのアンマウント時に preloadedQuery.dispose()
を呼び出すことです。
補足: Strict モード 対応の解説
Strict モードでは、アプリケーションの潜在的な問題点を洗い出すためレンダリング時にライフサイクルが2回呼ばれます。
これはつまり、コンポーネントが一度マウントされた後にアンマウントされ、再度マウントされるということです。
最初のアンマウントで即座に preloadedQuery
を破棄してしまうと、次のマウント時に破棄済みの preloadedQuery
が使われることになってしまい、 Relay が警告メッセージを出力してしまいます。
同様の理由から、 Strict モードではアンマウントも2回呼ばれます。破棄済みの preloadedQuery
を重複して破棄した場合にも Relay が警告メッセージを出力します。
したがって、最初のアンマウントから preloadedQuery
が破棄されるまでの間に遅延処理を入れ、かつ破棄時には既に破棄済みかどうかをチェックするという処理を入れることでこれを回避しているのです。
App
コンポーネントの実装
useRelayEnvironment()
を呼び出すことで、 RelayEnvironmentProvider
に渡すための environment
と、プリロード済みのクエリの情報が入った状態の transformedPageProps
が取得できます。
この transformedPageProps
をページコンポーネント(Component
) に渡すことで、ページ側に書かれた usePreloadedQuery()
が意図通りに動くようになります。
import type { AppProps } from "next/app";
import { RelayEnvironmentProvider } from "react-relay";
import { useRelayEnvironment } from "../graphql/client/environment";
import "../styles/globals.css";
function MyApp({ Component, pageProps }: AppProps) {
const { environment, transformedPageProps } = useRelayEnvironment(pageProps);
return (
<RelayEnvironmentProvider environment={environment}>
<Component {...transformedPageProps} />
</RelayEnvironmentProvider>
);
}
export default MyApp;
ページコンポーネントの実装
最後にページの実装です。
import type { NextPage } from "next";
import { graphql, PreloadedQuery, usePreloadedQuery } from "react-relay";
import { useDispose } from "../graphql/client/dispose";
import { preloadQuery } from "../graphql/client/preloadQuery";
import { RelayAppPageProps } from "../graphql/client/types";
import { sampleQuery } from "../graphql/__generated__/relay/sampleQuery.graphql";
import styles from "../styles/Home.module.css";
const query = graphql`
query sampleQuery {
hello
}
`;
type PageProps = {
initialPreloadedQuery: PreloadedQuery<sampleQuery>;
};
type InitialProps = PageProps | RelayAppPageProps;
const Sample: NextPage<PageProps, InitialProps> = ({
initialPreloadedQuery,
}) => {
const data = usePreloadedQuery<sampleQuery>(query, initialPreloadedQuery);
useDispose(initialPreloadedQuery);
return (
<div className={styles.container}>
<main className={styles.main}>
<h1 className={styles.title}>Next.js x Relay Example</h1>
{data.hello}
</main>
</div>
);
};
Sample.getInitialProps = () => {
return preloadQuery<sampleQuery>(query, {});
};
export default Sample
お疲れ様でした!
サンプルコード全体は以下のリポジトリに格納しています。
おまけ: Next.js と Transition と Suspense と私
終わりと見せかけてまだ続きます。
続きますが、ひとまずタイトルの「Next.js x Relay な GraphQL 環境で Render-as-you-fetch の良さを最大限生かしつつ SSR にも対応」する方法については前章までで全部説明しきっているので、以下はおまけみたいなものだと思ってください。
既にお気付きの方もいらっしゃると思いますが、今回紹介したコードの中には Suspense が一切登場しません。
しかし Sample
コンポーネントのレンダリング中に実行される usePreloadedQuery()
は、クエリのロードが未完了の場合(ナビゲーション時のみ発生)に Promise
を throw
してレンダリングをサスペンドしています。
これがうまくハンドリングできている理由は、 next/link によって Transition がサポートされているからです。
next/link によるナビゲーションでは Transition によりバックグラウンドで遷移先画面のレンダリングが開始されます。
Sample
コンポーネントのレンダリングが中断されても、 Transition の効果によりブラウザ上は遷移前の画面が表示されたままとなります。そしてデータフェッチが完了すると、速やかに Sample
コンポーネントの表示に切り替わります。
では逆に、 Transition を効かせずデータフェッチ中に何かしらのフォールバックを表示したい場合はどうすればよいでしょうか。
Transition は、中断した <Suspense>
があってもその外側のコンポーネントのレンダリングが完了すればその効果を終了します。つまり <Sample>
を <Suspense>
でラップしたものを返すようなラッパーコンポーネントがいればいいわけで、以下のようなコードであればフォールバックが表示されます。
const SampleWrapper = (props) => (
<Suspense fallback={<div>...Loading</div>}>
{/* SampleWrapper が preloadedQuery を取得して、 props として Sample に渡す */}
<Sample {...props} />
</Suspense>
);
SampleWrapper.getInitialProps = () => {
return preloadQuery<sampleQuery>(query, {});
};
でも、各ページに毎回ラッパーコンポーネントを書くのは面倒ですよね。
ということで、以下のようなヘルパーを用意してみました。
import { Suspense } from "react";
export const wrapSuspense = <C extends (props: any) => any>(Component: C): C =>
Object.assign(
(props: any) => (
<Suspense fallback={<div>...Loading</div>}>
<Component {...props} />
</Suspense>
),
Component
);
ちょっとトリッキーですが、『「引数に渡したコンポーネント を <Suspense>
でラップしたもの」を返すコンポーネント』を返してくれるように作ります。
これを使って以下のようにすると、
// ナビゲーション時に Transition にお任せしたい場合
// export default Sample
// ナビゲーション時にフォールバックを表示したい場合
export default wrapSuspense(Sample);
-
Sample
コンポーネントがレンダリングを中断する →<Suspense>
によりフォールバックがレンダリングされる -
<Suspense>
をラップしているコンポーネントのレンダリングが完了する → Transition が終了してフォールバックがブラウザ上に表示される
という2つが同時に成立するようになります。
まとめ
今度こそ本当に終わりです!こんなに長い記事を最後まで読んでいただきありがとうございます。
推敲に推敲を重ね、チームメンバーにレビューしてもらってそれを修正し……と繰り返しているうちに、 Next.js Conf 開催目前まで引っ張ってしまいました。Next.js Conf の発表内容次第では記事を加筆修正したり、場合によっては続編を書いたりしようと思っています。たいへんな遅筆なのでいつになるかはわかりませんが!