🐱

Relayでサーバーサイドでプリフェッチしたデータを使用する

に公開

今回はサーバーサイドでプリフェッチしたGraphQLデータを、Relayのクライアントサイドに渡す方法を紹介します。実装にはNext.jsを使用していますが、Remix(React Router)などでも応用できるはずです。

最終的なアウトプットを確認する

プリフェッチしたデータをRelayで扱うためには、usePreloadedQueryというカスタムフックを使用します。
ドキュメントを確認すると、このフックにはプリフェッチ済みのGraphQLデータを引数で渡す必要があることがわかります。

const data = usePreloadedQuery(graphqlTaggedNode, queryRef);

queryRefはクライアントサイドでのデータフェッチの場合、loadQueryあるいはuseQueryLoaderを利用して取得できます。しかし今回はサーバーサイドでプリフェッチしたいため、これらは使用しません。

PreloadedQueryの型定義を見ると、以下のような形式のデータを生成することができれば、Relayで取り扱えそうです。

export interface PreloadedQuery<
    TQuery extends OperationType,
    TEnvironmentProviderOptions = EnvironmentProviderOptions,
> extends
    Readonly<{
        kind: "PreloadedQuery";
        environment: IEnvironment;
        environmentProviderOptions?: TEnvironmentProviderOptions | null | undefined;
        fetchKey: string | number;
        fetchPolicy: PreloadFetchPolicy;
        networkCacheConfig?: CacheConfig | null | undefined;
        id?: string | null | undefined;
        name: string;
        source?: Observable<GraphQLResponse> | null | undefined;
        variables: VariablesOf<TQuery>;
        dispose: DisposeFn;
        isDisposed: boolean;
    }>
{}

また、RelayのloadQuery実装を読むと、getRequest関数を使うとGraphQLTaggedNodeから必要なパラメータをある程度取得できそうです。

https://github.com/facebook/relay/blob/f19f330f4e7614f36e612f75ee891fae46c8b09b/packages/react-relay/relay-hooks/loadQuery.js

サーバーローダーを自作してみる

サーバーサイドでGraphQLリクエストを行い、Relayで取り扱える形式のオブジェクトを返却するローダーを自作します。

// relay/createServerQueryPreloader.ts
import {
  ConcreteRequest,
  Environment,
  GraphQLTaggedNode,
  OperationType,
  PayloadData,
  PayloadError,
  QueryResponseCache,
  RequestParameters as RelayRequestParameters,
  Variables,
  VariablesOf,
  getRequest,
} from 'relay-runtime';

export type RequestParameters = RelayRequestParameters & {
  cacheID: string;
};

export type PreloadedServerQuery<TQuery extends OperationType> = {
  request: ConcreteRequest;
  response: { data: PayloadData; errors?: PayloadError[] };
  params: RequestParameters;
  variables: VariablesOf<TQuery>;
};

// サーバーサイドでGraphQLリクエストを行うためのローダー
export const createServerQueryPreloader = (
  fetchFn: (operation: RequestParameters, variables: Variables) => Promise<Response>,
) => {
  return async <TQuery extends OperationType>(
    gqlQuery: GraphQLTaggedNode,
    variables: VariablesOf<TQuery>,
  ): Promise<PreloadedServerQuery<TQuery>> => {
    const request = getRequest(gqlQuery) as ConcreteRequest & {
      params: RequestParameters; // getRequestで実際に返却される値と型定義に乖離があるためキャスト
    };

    const params = request.params;
    const operation = {
      cacheID: params.id ?? params.cacheID,
      id: null,
      text: params.text,
      name: params.name,
      operationKind: params.operationKind,
      metadata: {},
    };

    const response = await fetchFn(operation, variables);

    if (!response.ok) {
      throw new Error(
        `Error fetching GraphQL query '${operation.name}' with variables '${JSON.stringify(
          variables,
        )}'. Response: ${response.statusText}`,
      );
    }

    const json = await response.json();

    return {
      request,
      response: json as { data: PayloadData; errors: PayloadError[] },
      params: operation,
      variables,
    };
  };
};

const ONE_MINUTE_IN_MS = 60 * 1000;

export const responseCache = new QueryResponseCache({
  size: 100,
  ttl: ONE_MINUTE_IN_MS,
});

// サーバーサイドでプリフェッチしたデータをRelayの形式に変換する
export const serializePreloadedServerQuery = <TQuery extends OperationType>(
  environment: Environment,
  preloadedQuery: PreloadedServerQuery<TQuery>,
  fetchPolicy: PreloadFetchPolicy = 'store-or-network',
): PreloadedQuery<TQuery> => {
  // クライアントサイドでのリクエストをキャッシュから解決するためにセットする
  responseCache.set(
    preloadedQuery.params.cacheID,
    preloadedQuery.variables,
    preloadedQuery.response,
  );

  return {
    kind: 'PreloadedQuery',
    environment,
    fetchKey: preloadedQuery.params.id ?? preloadedQuery.params.cacheID,
    fetchPolicy,
    id: preloadedQuery.params.cacheID,
    name: preloadedQuery.params.name,
    variables: preloadedQuery.variables,
    dispose: () => {
      return;
    },
    isDisposed: false,
  };
};

上記では、createServerQueryPreloaderがサーバーサイドで実際にGraphQLフェッチを行い、その結果をPreloadedServerQuery形式で返却します。さらにserializePreloadedServerQueryでRelayが扱える形 (PreloadedQuery) に変換し、レスポンスキャッシュにも格納しています。

クライアントサイドでプリフェッチ済みデータを解決する

サーバーサイドでプリフェッチしたデータを、クライアントサイドのRelayストアで利用できるように設定します。

// relay/RelayEnvironment.ts
import {
  Network,
  Observable,
  Variables,
  RequestParameters as RelayRequestParameters,
  CacheConfig,
  UploadableMap,
} from 'relay-runtime';
import { RequestParameters, responseCache } from './createServerQueryPreloader';

const fetchQuery = async (operation: RequestParameters, variables: Variables) => {
  return await fetch('{エンドポイントのパス}', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      query: operation.text,
      variables,
    }),
  });
};

export const RelayEnvironment = new Environment({
  network: Network.create(async (operation, variables) => {
    const id = operation.id ?? (operation as RequestParameters).cacheID;
    const cache = responseCache.get(id, variables);

    // サーバーからフェッチしたデータが存在する場合はキャッシュを返却して処理を終了
    if (cache) return cache;

    const response = await fetchQuery(operation, variables);

    return response.json();
  }),
  store: new Store(new RecordSource()),
});

// サーバー用のローダー
export const loadServerQuery = createServerQueryPreloader(fetchQuery);

ネットワークレイヤー内でresponseCacheを確認し、すでにサーバーで取得・キャッシュされたデータがあればそのまま返却するようにしています。

次に、サーバーでフェッチしたデータをusePreloadedQueryで利用できるようにするための専用フックを用意します。

// relay/useServerPreloadedQuery.ts

'use client';

import { useMemo } from 'react';
import { PreloadedServerQuery, serializePreloadedServerQuery } from './createServerQueryPreloader';
import { Environment, GraphQLTaggedNode, OperationType } from 'relay-runtime';
import { usePreloadedQuery } from 'react-relay';

export const useServerPreloadedQuery = <TQuery extends OperationType>(
  environment: Environment,
  gqlQuery: GraphQLTaggedNode,
  preloadedQuery: PreloadedServerQuery<TQuery>,
) => {
  const queryRef = useMemo(
    () => serializePreloadedServerQuery(environment, preloadedQuery),
    [environment, preloadedQuery],
  );

  return usePreloadedQuery(gqlQuery, queryRef);
};

実際に使用してみる

ここでは、ExampleというオブジェクトをNodeリゾルバーで取得できるサーバーがある想定でコードを例示します。

// Example/preload.ts
import { graphql } from 'react-relay';

export const PRELOAD_APP_SHELL_QUERY = graphql`
  query preload_ExampleQuery {
    node(id: "id") {
      ... on Example {
        id
      }
    }
  }
`;
// Example/ExamplePreloaded.tsx
import { PreloadedServerQuery } from '@/relay/createServerQueryPreloader';
import { useServerPreloadedQuery } from '@/relay/useServerPreloadedQuery';
import { RelayEnvironment } from '@/relay/RelayEnvironment';
import { preload_ExampleQuery } from './__generated__/preload_ExampleQuery.graphql';
import { PRELOAD_APP_SHELL_QUERY } from './preloaded';

type Props = {
  preloaded: PreloadedServerQuery<preload_ExampleQuery>;
}

export const ExamplePreloaded = ({ preloaded }: Props) => {
  const data = useServerPreloadedQuery(RelayEnvironment, PRELOAD_APP_SHELL_QUERY, preloaded);

  return <div>{data.id}</div>
}
// Example/index.tsx
import { PRELOAD_APP_SHELL_QUERY } from './preloaded';
import { loadServerQuery } from '@/relay/RelayEnvironment';
import { ExamplePreloaded } from './ExamplePreloaded';

export const Example = async () => {
  const preloaded = await loadServerQuery<preloaded_ExampleQuery>(PRELOAD_APP_SHELL_QUERY, {});

  return <ExamplePreloaded preloaded={preloaded}  />
}

ブラウザ上で確認すると、クライアントサイドでのGraphQLリクエストが発火しておらず、サーバーサイドでプリフェッチしたデータをRelayで読み込めていることがわかります。

以上!

Discussion