スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

@graphql-codegen/typescript-react-apolloとの思い出

こんにちは、 Web フロントエンドエンジニアの @progfay です。

この記事は Recruit Engineers Advent Calendar 2022 の 12 日目の記事です。

今回はプロジェクト内で使っている @graphql-codegen/typescript-react-apollo package との思い出を書き綴っていきます。

出会い

私の所属するスタディサプリ中学講座の開発プロジェクト (通称: tara) では通信に GraphQL を採用しています。 また、 GraphQL Code Generator を使って GraphQL にまつわる型や関数の生成を行なっています。

Web Frontend では API Client として Apollo Client を使用しており、 TypeScript と React と Apollo Client を合わせて便利に扱うために @graphql-codegen/typescript-react-apollo plugin が用意されています。

Apollo Client を使った Request は以下のように書かれます。

import { gql, useQuery } from '@apollo/client'

const SAMPLE_QUERY = gql`
query SampleQuery($cardId: String!) {
  card(cardId: $cardId) {
    title
    ownerName
    createdAt
  }
}
`

const Sample = () => {
  const {
    loading,
    data, // any型
  } = useQuery(
    SAMPLE_QUERY,
    { variables: { cardId: "0123" } } // 型推論は効かない
  )

  // :
}

これが GraphQL Code Generator と @graphql-codegen/typescript-react-apollo を使うと以下のようになります。

// GraphQL Code Generator により生成されたファイルから import
import { useSampleQuery } from './__generated__/graphql'

const SAMPLE_QUERY = gql`
query SampleQuery($cardId: String!) {
  card(cardId: $cardId) {
    title
    ownerName
    createdAt
  }
}
`

const Sample = () => {
  const {
    loading,
    data, // 自動生成された型が付与されている
  } = useSampleQuery(
    { variables: { cardId: "0123" } } // 型推論が効く!
  )

  // :
}

Query の引数や返り値に型が付与されたり、 Query ごとに custom hooks が生成されたりと、開発をより快適に行えるようになります。 この恩恵として無駄な記述が消えたり、うっかり型の指定を間違えることがなくなったりと、開発における面倒臭さを緩和できます。

不満: 毎回同じコードを書きたくない

どんなに便利なものでも、使っていく中で「もっとこうしたい」という不満は出てきます。 我々のプロジェクトではそれが「GraphQL Request に失敗したら自動で Error を throw してほしい」でした。 というのも、我々のプロジェクトでは GraphQL Request に失敗したら出す画面が用意されており、 Error Boundary に特定の Error が catch されるとそれが表示されるようになっていました。

しかし Apollo Client の提供する useQuery は Error を throw せず、返り値に error: ApolloError | undefined を持ちます。 そのため、都度以下のような実装が必要になっていました。

const Sample = () => {
  const { loading, data, error } = useSampleQuery()
  if (error !== undefined) throw error

  // :
}

しかし、毎回この記述をするのは面倒だし書き忘れることもしばしばありました。 その結果、開発の中で GraphQL Request に失敗するとローディング画面から抜け出せない事象などがいくつか発生していました。 これの問題点はエラーハンドリングの処理を開発者が記述するような、いわば Opt-in のような仕組みであることだと分析しました。 であれば「デフォルトでエラーハンドリングを行うような処理に書き換えられないのか?」という方向性に話が進みました。

Error Handling by default

修正にあまりコストをかけないために、 @graphql-codegen/typescript-react-apollo に用意されている機能を使って実現できないかを考えてみました。 公式ドキュメントやソースコードを読んでみると @graphql-codegen/typescript-react-apollo には複数の config が用意されていました。

この中に useQuery を import する先を指定できる apolloReactHooksImportFrom config があり、これを利用して Apollo Client の useQuery を wrap したものを使わせる方向で実装をしてみました。

// src/lib/apollo/client.ts
import { useEffect } from 'react'
import { useQuery as useQueryOriginal } from '@apollo/client'
import type * as Apollo from '@apollo/client'

export interface QueryHookOptions<TData = any, TVariables = Apollo.OperationVariables>
  extends Apollo.QueryHookOptions<TData, TVariables> {
  readonly skipErrorHandling?: boolean
}

export function useQuery<TData = any, TVariables = Apollo.OperationVariables>(
  query: Apollo.DocumentNode | Apollo.TypedDocumentNode<TData, TVariables>,
  options?: QueryHookOptions<TData, TVariables>
) {
  const result = useQueryOriginal(query, options)
  if (!options?.skipErrorHandling && result.error !== undefined) throw result.error
  return result
}
// codegen.yaml
generates:
  src/__generated__/graphql.tsx:
    plugins:
      - typescript
      - typescript-operations
      - typescript-react-apollo
    config:
      apolloReactHooksImportFrom: ../apiClient/apollo/client

これにより useSampleQuery などの GraphQL Code Generator によって生成された Custom Hooks は内部的に src/lib/apollo/client.ts で定義されたデフォルトで Error Handling を行う useQuery を使うようになりました。 また useSampleQuery({ skipErrorHandling: true }) のように記述することで Error Handling を Opt-Out することも可能です。

この仕組みによってエラーハンドリング処理を記述する煩わしさや書き忘れはほぼ 0 になりました。

Apollo CLI からの乗り換え

時は 2022 年 10 月、 Node.js の Active LTS が v16 から v18 に移ったため、我々もこれに追従することとしました。 Node.js v18 移行のために npm package の version を上げていくと、 Apollo CLI が当時の最新版でも Node.js v18 に対応していませんでした。 Node.js v18 に対応して欲しいという issue を立てたり、自分で Apollo CLI 本体を Node.js v18 に対応させられないかと格闘したりと頑張ってみましたが、そもそも Apollo CLI は deprecated という扱いになっていました。

(Apollo CLI が Node.js v18 に対応していない問題の根本原因であった @apollo/federation package は v0.38.1 にて Node.js v18 に追従しました、 Special Thanks @glasser!)

そのため、これを機に Apollo CLI から別の手段への乗り換えを考えはじめました。

v.s. Persisted Query

我々のプロジェクトでは GraphQL の Persisted Query を利用しており、そのために Apollo CLI を使っていました。 逆にそれ以外の用途では使われていませんでした。 つまり、 Persisted Query を別の方法で生成できれば Apollo CLI からの乗り換えを達成できるということで、 GraphQL Code Generator でこれができないかを考えてみました。

GraphQL Code Generator の plugin として graphql-codegen-persisted-query-ids があります。 これを @graphql-codegen/typescript-react-apollo と上手く併用するためには少し工夫をしてあげる必要がありそうでした。

Persisted Query は以下のような手順で処理を行います。

  1. Client 側で GraphQL の Query を正規化し hash を取る
  2. hash を HTTP Request に載せて API を叩く
  3. Server 側で hash が既知かを検証する (信頼できる Client からリクエストされるであろう Query の hash を前もってリストアップしておく必要がある)
  4. 既知の hash だった場合は対応する Response を生成し返す

これを実現するためには以下の 2 つを実装する必要があります。

  • Client 側で GraphQL の Query を正規化し hash を取る
  • 信頼できる Client からリクエストされるであろう Query の hash を前もってリストアップしておく

Apollo CLI では Runtime で Query の正規化と hash 生成を行なっていました が、 graphql-codegen-persisted-query-ids では GraphQL Code Generator によるコード生成時に正規化と hash 生成を行うため、 Runtime での処理や正規化・ hash 生成のためのライブラリが不要になりました、最高。

しかし、コード生成時には @graphql-codegen/typescript-react-apollo による型情報や Custom Hooks のコードと graphql-codegen-persisted-query-ids による Persisted Query の hash 情報は別々のファイルに吐き出されるため、これらを対応づける処理を自前で書く必要があります。

方法は様々ありますが、今回は @graphql-codegen/typescript-react-apollo によって自動生成されたファイルの末尾に以下のような行を Query の数だけ追加するスクリプトを実行することで解決しました。

SampleQueryDocument.__hash = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'

SampleQueryDocumentquery SampleQuerygql によって parse した結果の AST です。 この Object に __hash property を拡張し、 persistedQueryLink の中でこの hash を取り出してあげる実装を採用しました。

import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries'

const persistedQueryLink = createPersistedQueryLink({
  useGETForHashedQueries: true,
  generateHash: (document) => {
    if (document.__hash === undefined) {
      throw new Error('document hash is not found')
    }
    return document.__hash
  },
})

const client = new CustomizedApolloClient({ link: [persistedQueryLink, /* and more... */] })

これで graphql-codegen-persisted-query-ids を用いた Persisted Query に乗り換えることに成功し、 Apollo CLI からの乗り換えを果たしました!

@graphql-codegen/typescript-react-apollo からの乗り換えの時は近い...

ここまで改造を重ねて使い続けてきた @graphql-codegen/typescript-react-apollo ですが、他にも改善希望は出てきています。

  • Error Handling の実装を wrap した関数を経由するのではなく、 custom hooks に直に展開したい
  • gql での parse を Runtime で行わないようにしたい
  • GraphQL Query を bundle から取り除きたい (Persisted Query の hash のみを bundle に含めたい)

これらを実現するためには @graphql-codegen/typescript-react-apollo から自前で実装した plugin に乗り換えるのが一番現実的かなと考えています。 やっていきたい気持ちはあるため、これが実現したらまたブログにしようかなと思います。

おわりに

本記事では、 @graphql-codegen/typescript-react-apollo の改造の記録をご紹介しました。

スタディサプリでは、一緒に Web Frontend の開発体験をより良くしていく仲間を募集しています。

https://brand.studysapuri.jp/career/

また、 Recruit Engineers Advent Calendar 2022 ではリクルートのエンジニア陣が記事を投稿していく予定です。 もしリクルートにおけるエンジニアリングに興味があれば、ぜひ他の記事もあわせてご参照ください。