React プロジェクトで Apollo Client 4.0 への移行にあたり実装を最適化した話

に公開

2025/08/22 に Apollo Client 4.0 がリリースされ、移行のためのドキュメントも公開されたので、移行にあたり実装を最適化したので紹介していく。

また、2025/09 時点では、Apollo Client 関連のユーティリティパッケージはほとんどが 4.0 に対応しておらず、この辺の移行作業についても触れていく。

typescript-react-apollo もうダメそう

筆者のプロジェクトでは、開発を効率化するために GraphQL Codegen を使用している。
GraphQL Codegen のプラグインとして、スキーマを React の Hooks として生成できる便利なパッケージ typescript-react-apollo を使用している。
執筆時点でも週間80万のペースでダウンロードされている有名なパッケージだが、そもそも Hooks の生成は型のオーバーロードが多く型の安全性的に非推奨とのことで、GraphQL Codegen が提供しているプリセットの使用が強く推奨されている。
typescript-react-apollo も廃止予定となっている。(graphql-code-generator-community より)

推奨される書き方に素直に移行する

GraphQL Codegen ではプラグインの使用を全てやめる決断をし、プリセットを使用するようにオプションを変更した。

const config: CodegenConfig = {
  documents: "src/**/*.graphql",
  generates: {
    "src/codegen/": {
      config: {
-       dedupeFragments: true,
        enumsAsConst: true,
-       withHooks: true,
+       skipTypename: true,
      },
+     preset: "client", // 公式推奨のプリセット
+     presetConfig: {
+       fragmentMasking: false,
+     },
-     plugins: [
-       "typescript",
-       "typescript-operations",
-       "typescript-react-apollo",
-     ],
    },
    ...
  },
  overwrite: true,
  schema: ...,
};

クエリを実行する部分も Apollo Client の推奨の書き方に変更した。
まず gql ドキュメントを定義する。

import { gql } from "@apollo/client";

const LOGIN_USER = gql`
  query LoginUser($userId: String) {
    user(userId: $userId) {
      name
      ...
    }
  }
`;

export default LOGIN_USER;

typescript-react-apollo で生成された Hooks を使用していた箇所を、useQuery・useMutation を使って書き直した。
ジェネリクス型にはプリセットで生成された型を当て、定義した gql ドキュメントを引数とする。
これで Apollo Client, GraphQL Codegen が推奨する形の実装にできたと思われる。

-const { data } = useLoginUserQuery({
+const { data } = useQuery<
+  LoginUserQuery,
+  LoginUserQueryVariables
+>(LOGIN_USER, {
  skip: !userId,
  variables: {
    userId: userId,
  },
});

4.0に対応していないパッケージの移行

対応していないパッケージの扱いは以下のようにした。

依存関係の問題

対応していないパッケージでは依存関係で Apollo Client 4.0 が除外されている。
この場合、取れる対応としては以下の3つとなると考えられる。

  • 案1:パッケージをアンインストールし、パッケージのロジックをローカルプロジェクトで実装する、もしくはフォークして修正したパッケージをインストールする
    • デメリット:そのパッケージのアップデートを受け取れなくなる
    • デメリット:コードの解析、修正に時間がかかる
  • 案2:依存関係をオーバーライドした上でパッケージをそのまま使用し、Apollo Client に関係しないメソッドのみインポートして使用する、Apollo Client に関係する部分はローカルプロジェクトで実装する
    • デメリット:依存関係のオーバーライドをしなければならない
    • デメリット:コードの解析に時間がかかるが 案1 ほどではない
  • 案3:パッケージのアップデートを待つ
    • デメリット:依存パッケージが全てアップデートされるまで Apollo Client 4.0 への移行ができない

筆者は上記の 案1 でパッケージの移行を進めた。

具体例

筆者のプロジェクトでは、GraphQL のインフラに AWS AppSync、承認に AWS Cognito を使用しているため、ApolloLink 生成時にこれらを考慮するため以下のパッケージを使用している。

  • aws-amplify
    • Cognito の認証周りをハンドリングするために使用
  • aws-appsync-auth-link
    • 認証情報を解析しリクエストヘッダにセットした ApolloLink を生成できる、4.0 未対応

Before
対応前は以下のようなコードとなっていた。

  1. aws-amplify の Auth インスタンスのメソッドで認証情報を取得
  2. aws-appsync-auth-link の createAuthLink メソッドに渡す
  3. 生成された ApolloLink を受け取る
  4. ApolloClient のインスタンスに ApolloLink をセットする
import { Auth } from "aws-amplify";
import { AuthLink, createAuthLink } from "aws-appsync-auth-link";

// AuthLink は ApolloLink の拡張型インスタンスです
function getAuthLink({ authType }: GetAuthLinkParams): AuthLink {
  const authLink = createAuthLink({
    auth:
      authType === "AMAZON_COGNITO_USER_POOLS"
        ? {
            jwtToken: async (): Promise<string> =>
              (await Auth.currentSession()).getIdToken().getJwtToken(),
            type: "AMAZON_COGNITO_USER_POOLS",
          }
        : {
            credentials: (): Promise<ICredentials> => Auth.currentCredentials(),
            type: "AWS_IAM",
          },
    region: ...,
    url: ...,
  });

  return authLink;
}

...

export default function getClient({ authType }: GetClientParams): ApolloClient {
  const authLink = getAuthLink({ authType });
  const client = new ApolloClient({
    cache: new InMemoryCache(),
    link: ApolloLink.from([authLink, httpLink]),
    ...
  });

  return client;
}

しかし、Apollo Client 4.0 では ApolloLink インスタンスの挙動が大幅に変更されたのと、認証情報を await で取得しているのが原因で、createAuthLink メソッドでTypeError: Class constructor ApolloLink cannot be invoked without 'new'が発生してしまった。
解決策としては、認証情報の取得を ApolloLink インスタンスの observer に含める必要があるが、ApolloLink インスタンス は aws-appsync-auth-link の createAuthLink メソッドで生成されるためそれができない。
そのため、aws-appsync-auth-link の使用は廃止し、ローカルプロジェクトでApolloLink インスタンスの生成をするようにし、署名ロジックを実装した。

After
対応後は以下のようなコードとなった。(変更箇所は太字)

  1. ApolloLink インスタンス を生成し、Observer を定義する
  2. 以下を Observe させる
    1. aws-amplify の Auth インスタンスのメソッドで認証情報を取得
    2. 署名V4パッケージを使用し署名する
    3. リクエストヘッダーを定義する
  3. 生成された ApolloLink を受け取る
  4. ApolloClient のインスタンスに ApolloLink をセットする
function getAuthLink({ authType }: GetAuthLinkParams): ApolloLink {
  const authLink = new ApolloLink((operation, forward) => {
    return new Observable((observer) => {
      (async (): Promise<void> => {
        try {
          if (authType === "AMAZON_COGNITO_USER_POOLS") {
            const session = await Auth.currentSession();
            const idToken = session.getIdToken().getJwtToken();
            operation.setContext({
              headers: {
                Authorization: idToken,
              },
            });
          } else {
            const credentials = await Auth.currentCredentials();
            const { accessKeyId, secretAccessKey, sessionToken } = credentials;

            // NOTE: 署名V4を生成する
            const signatureV4 = new SignatureV4({
              credentials: {
                accessKeyId,
                secretAccessKey,
                sessionToken,
              },
              region: process.env.NEXT_PUBLIC_AWS_PROJECT_REGION || "",
              service: "appsync",
              sha256: Sha256,
            });

            const url = new URL(
              process.env.NEXT_PUBLIC_AWS_APPSYNC_GRAPHQL_ENDPOINT || "",
            );

            // NOTE: 実際に送られる GraphQL リクエストボディを署名用に生成する
            const { operationName, query, variables } = operation;

            // NOTE: プロパティの順番を担保しなければならないので Linter を無視する
            /* eslint-disable */
            const body = JSON.stringify({
              operationName: operationName,
              variables: variables || {},
              extensions: {
                clientLibrary: { name: "@apollo/client", version: version },
              },
              query: print(query),
            });
            /* eslint-enable */

            const httpRequest = new HttpRequest({
              body,
              headers: {
                accept: "*/*",
                "content-type": "application/json; charset=UTF-8",
                host: url.hostname,
              },
              hostname: url.hostname,
              method: "POST",
              path: url.pathname,
            });

            const signedRequest = await signatureV4.sign(httpRequest);

            operation.setContext({
              headers: {
                accept: "*/*",
                authorization: signedRequest.headers.authorization,
                "content-type": "application/json; charset=UTF-8",
                "x-amz-content-sha256":
                  signedRequest.headers["x-amz-content-sha256"],
                "x-amz-date": signedRequest.headers["x-amz-date"],
                "x-amz-security-token":
                  signedRequest.headers["x-amz-security-token"],
                "x-amz-user-agent": "aws-amplify",
              },
            });
          }

          const observable = forward(operation);
          observable.subscribe({
            complete: observer.complete.bind(observer),
            error: observer.error.bind(observer),
            next: observer.next.bind(observer),
          });
        } catch (error) {
          observer.error(error);
        }
      })();
    });
  });

  return authLink;
}

...

export default function getClient({ authType }: GetClientParams): ApolloClient {
  const authLink = getAuthLink({ authType });
  const client = new ApolloClient({
    cache: new InMemoryCache(),
    defaultOptions: {
      watchQuery: {
        fetchPolicy: "network-only",
      },
    },
    link: ApolloLink.from([authLink, httpLink]),
  });

  return client;
}
フクロウラボ エンジニアブログ

Discussion