GraphQLライブラリをApollo→Relay→Urqlにハシゴした話

先日、Recoilで快適フロントエンド開発という記事を書いた藤田です。

GraphQLクライアントライブラリ乗り換え遍歴

私達のプロジェクトではReactのフロントエンドとバックエンドの通信にGraphQLを使っています。

GraphQLは、たいていの場合はHTTP POSTリクエストで

  • リクエストボディ:GraphQLクエリ(文字列)と引数(オブジェクト)からなるJSON
  • レスポンスボディ:データJSON

をやりとりするだけというだけのシンプルなプロトコルなので、全てfetch関数で頑張るストロングスタイルで行けないこともないですが、やっぱり専用のクライアントライブラリを利用したほうが楽です。

そのライブラリとして一番有名なApollo Clientから始まってRelayUrqlと、3ヶ月くらいの間に2回も乗り換えてしまったので、反省の意味も込めて記事にしたいと思います。

GraphQLクライアントライブラリがいろいろあってどう違うんだろうと迷った方の助けになれたら嬉しいです。

最初:Apollo Client

開発の最初期は、単に「一番有名だから」という理由でApollo Clientを使っていました。

コード例

Apollo Clientでクエリーを呼び出すコードはだいたい下記のようになります。

const GET_DOGS = gql`
  query GetDogs {
    dogs {
      id
      breed
    }
  }
`

const DogSelector: React.FC = () => {
  const { loading, error, data } = useQuery(GET_DOGS)

  if (loading) return 'Loading...'
  if (error) return `Error! ${error.message}`

  return (
    <select onChange=...(略)....>
      {data.dogs.map(dog => (
        <option key={dog.id} value={dog.breed}>
          {dog.breed}
        </option>
      ))}
    </select>
  )
}

useQueryの返すオブジェクトにloading、error、dataという値がそれぞれ含まれていて、読込中ならloadingがtrueになり、エラーがあればerrorに値が入り、成功したらdataに値が入っています。

useQueryの中ではuseStateやuseContextを駆使して、ネットワークからの応答があったらコンポーネント全体が再描画されるとか、同じクエリー、同じ引数ならキャッシュしてある結果を返すとかを行っています。とてもうまいやり方だと思います。

GraphQL Code Generatorでさらに便利に

GraphQL Code GeneratorとそのプラグインTypeScript React Apolloを使えば、上記のコードから「GraphQLクエリー宣言」と「useQueryフック呼び出し」が自動生成のフック関数に隠蔽されて、こうなります。

const DogSelector: React.FC = () => {
  // この関数が自動生成される。型もしっかり定義される
  const { loading, error, data } = useGetDogsQuery()
   :
  (以下同じ)
}

GraphQLスキーマファイルとquery GetDogsのクエリーは別ファイルにしてあり、codegen.ymlで置き場所を指定しています。また、コード中の変数dataにはきっちり定義された型がついているので値を利用するときにIDEの補完が効いて快適です。

乗り換えようと思ったきっかけ

先日の記事で書いたとおり、私達はReactの状態管理にRecoilを採用しています。RecoilはReact 16の新機能であるSuspenseに対応しています。

Suspenseとは、ネットワーク通信中などにPromiseオブジェクトをthrowすることで、そのコンポーネントの外側にある<Suspense>コンポーネントに通信終了後の再描画をやってもらうという仕組みです。また、そのPromiseがエラー終了(reject)した場合のためのError Boundaryという仕組みもあります。

つまり、GraphQL通信の部分にSuspenseとError Boundaryが適用できれば、従来必須だった

  if (loading) return 'Loading...'
  if (error) return `Error! ${error.message}`

という2つの条件分岐は無くせるはずなのです。しかし2021年10月時点でApollo Clientはそれらに対応していませんでした(2022年2月現在、おそらくまだ対応していないと思われます)。自分で作るのも難しそうでした。

Recoilで状態変化している部分ではSuspenseを積極的に使っているのに、一番Suspenseを使いたいサーバー通信の部分で使えないのはとても不満でした。

2つ目:Relay

ReactやRecoilと同じくMeta(旧Facebook)によるGraphQLライブラリであるRelayはさすが開発元だけあって、通信中と通信エラーの扱いをSuspenseとError Boundaryに委ねる形になっていました。

これはいい、ということで調査したところ、Apollo Clientとは随分違う使い勝手でした。

具体的な実装手順

最初にGraphQLのスキーマファイルをプロジェクト内に配置した後、次のようなステップで開発をしていきます。

  1. GraphQLのqueryやmutation等を含むTypeScriptコードを書きます。このときqueryの名前はファイル名から始まっている必要があります。これはRelayの「コンポーネントで使いたいgraphqlはそのコンポーネントのファイル内に記述すればいいじゃない」という思想によります。
    例えば「DogSelector」というコンポーネントを作る場合は以下のようになります。

    // "DogSelector.tsx"ファイル内に記述
    const getDogsQuery = graphql`
      query DogSelector_getDogsQuery {
        dogs {
          id
          breed
        }
      } 
    `
  2. コマンドライン等からrelay-compilerを実行します(実際はファイルの更新を監視するなどして自動化するのがいいでしょう)。すると上記コードのクエリー、引数、戻り値に対応したtypeが生成されます。上記コードを編集したり削除したりしてからもう一度compileすればちゃんと追従してくれたり、キャッシュ機構を制御するための情報がぎっしり入ってる複雑なコードブロックも一緒にできます。
    (別ファイルにこんなコードが生成されます)
    export type DogSelector_getDogsQueryVariables = {
    };
    export type DogSelector_getDogsQueryResponse = {
      readonly dogs: {
        readonly id: string
        readonly breed: string
      }| null
    };
    export type DogSelector_getDogsQuery = {
      readonly response: DogSelector_getDogsQueryResponse;
      readonly variables: DogSelector_getDogsQueryVariables;
    };
    (以下、複雑な部分は略)
  3. クエリを実行するために、「外側」と「内側」の2つのReact Componentを作ります。
    // 外側
    export const DogSelector: React.FC = () => {
      // 以下未実装
    }
    
    // 内側 
    type InnerProps = {
      // 以下未実装
    }
    const Inner: React.FC<InnerProps> = (props) => {
      // 以下未実装
    }
  4. 「外側」Componentで専用のフック関数からクエリー実行用オブジェクト(PreloadedQuery<自動生成された型>)とクエリー実行関数を取得します。
    // 型の名前が長いのでブログ記事の横幅のためにエイリアス 
    type GetDogs = DogSelector_getDogsQuery 
    
    // 外側
    export const DogSelector: React.FC = () => {
      const [preload, load] = 
        useQueryLoader<GetDogs>(getDogsQuery)
      useEffect(() => {
        // 初回描画時に実行
        load({})
      }, [])
      // 以下未実装
    }
  5. 「内側」ComponentではPreloadedQueryを受け取り、また別のフック関数で実行結果を取り出します。
    // 内側
    type InnerProps = {
      preload: PreloadedQuery<GetDogs>
    }
    
    const Inner: React.FC<InnerProps> = (props) => {
      const {preload} = props;
      const result = 
        usePreloadedQuery<GetDogs>(getDogsQuery, preload)
      return ( <select onChange=...(略)...>
        {data.dogs.map(dog => (
           <option key={dog.id} 
                   value={dog.breed}>
             {dog.breed}
           </option>
        ))} 
      </select> )
    }
  6. 「外側」Componentで、「内側」ComponentをSuspenseで囲んで返します。
    // 外側
    export const DogSelector: React.FC = () => {
      const [preload, load] = 
        useQueryLoader<GetDogs>(getDogsQuery) 
      useEffect(() => ...(変化なし)... )
      return preload ? (
        <React.Suspense fallback={<Loading />}>
          <Inner preload={preload} />
        </React.Suspense>
      ) : null
    }

この手順をひと目見た段階では、「面倒くさい!」と感じる方が大半ではないかと思います。確かに手数は多いです。しかし、手数に見合うメリットもちゃんとあります。

Relayのメリット

Suspenseに対応している

読込中の状態はReactの状態管理で自分で分岐するのではなく、Suspenseに任せてしまえます。ただ、「外側」と「内側」の2つのComponentを作る必要がありコード量は増えてしまうので、コードのシンプルさを求めるなら本末転倒というか…

キャッシュが賢い

Relayはグラフ理論に基づいてキャッシュを管理しています。たとえばDogオブジェクトの一覧を一度取得すると結果をキャッシュして、その後同じクエリをリクエストしてもネットワーク通信が発生しません。

ここまでは普通ですが、例えば取得した一覧の中にid:"dog-0001"というDogオブジェクトが含まれていたとします。

その後mutationを実行してid:"dog-0001"のDogを更新すると、Relayはなんと保持しているキャッシュ中の同idのオブジェクトも更新します。するともう一回一覧取得した時はネットワーク通信無しで瞬時に値が返るにもかかわらず、id:"dog-0001"のオブジェクトはちゃんと新しくなっています。

GraphQL Subscriptionを利用してリアルタイムに更新や削除イベントを受け取るようにしておけば、プログラマーは自分でデータストアを管理していちいち操作しなくても、Relayが返すキャッシュをそのままデータストアとして使えるので、コード量が減ります。

サーバー側がGraphQLの一般的なガイドラインに沿っていないと警告してくれる

GraphQLサーバーは簡単に作るだけなら、わりと簡単に自由奔放なスキーマ定義で作れてしまいます。しかし、きちんと理論を押さえた標準的なスキーマ定義の方針が推奨されていて、それらに準拠するほうが長い目で見れば好ましいはずです。

RelayのサイトではGraphQL Server Specificationという文書が公開されています。これはgraphql.orgで公開されているGraphQLサーバーの実装標準にほぼ沿っていて、短くまとまっています。

恥ずかしながら、私達はそれをちゃんと読まずにGraphQLサーバーを実装していました。Apollo Clientを使っていたときはなんともなかったのですが、Relayに乗り換えたらブラウザのログに「__typename属性が別なのに同じid属性を持つオブジェクトが返された」という警告が出ました。

GraphQLでは「ID」型を持つ「id」フィールドは全て、typeによらずグローバルに一意でなければならないのですが、サーバーの実装がそのようになっていなかったので、きちんとガイドラインに沿うように修正しました。勉強になりました。

乗り換えようと思ったきっかけ

Relayはサーバー側がきちんとグラフ理論に基づいていて、なおかつsubscribeによるプッシュ系の操作もきちんと備えていることを前提に作られています。そのため、たとえばGithubのGraphQL APIのように全てが教科書どおりに作られていて、一見冗長に見えるけれど理論的に正しいというようなサーバーのクライアントとしては超強力といえます。

しかし、私達のプロジェクトは

  • 社内向け
  • 少人数チーム
  • サーバーもフロントも仕様をどうしようか迷いながら積み上げている最中
  • 将来にわたってAPI公開の予定は無い

といった様相です。GraphQLにはあくまでBackend for Frontendとしてスキーマと型の正しい定義をサーバー自身が返してくれる、便利なREST程度(ちょっと語弊があるか?)の機能しか求めていません。

わりと複雑なUIの仕様を試行錯誤しながらちょこちょこ変化させていく状況では、開発手順の多さがどうしても気になってきてしまいました。要するに面倒さに耐えきれなくなりました。

3つ目:Urql

そんな折、Apollo Client代替として真っ先に候補に上がるUrqlが、いつのまにかSuspenseに対応していたことに気づきました。以前調べた時は、VueのSuspense対応はあってもReactのSuspense対応がなかったように思ったのですが……。今見たら2021年2月のver 2.0.0でReact Suspense対応とあるので、単に見つけられていなかっただけかもしれません。

Urqlを使ったコード

Urqlはフック関数の仕様がApollo Clientとそっくりなので、前に書いていたコードを思い出せばすぐ書けました。

基本的には公式ドキュメントのReact/Preact Bindingsに沿って書いていきますが、Suspense対応にするために、Clientインスタンスを生成するところにちょっと記述を加えます。

const client = createClient({
  url: `(アクセス先URL)`,
  suspense: true, // ←これを加える
  exchanges: [
    dedupExchange,
    suspenseExchange, // ←これを加える
    cacheExchange,
    fetchExchange,
  ],
  // Cookieを送信したい場合は加える
  fetchOptions: () => ({
    credentials: 'include',
  }),
})

これだけで、useQueryフックがSuspense対応になってくれます。偉い!

Apollo Clientのときと同様、GraphQL Code GeneratorのプラグインTypeScript Urqlでフック関数を自動生成して、以下のように書きます。

const DogSelector: React.FC = () => {
  // この関数が自動生成される。型もしっかり定義される
  const { data } = useGetDogsQuery()

  return ( 
    <select onChange=...(略)....> 
    {data.dogs.map(dog => ( 
      <option key={dog.id} 
              value={dog.breed}> 
        {dog.breed}
      </option>
    ))}
    </select> 
  )
}

UrqlのuseQueryフックは通常、Apollo Clientと全く同様にloading, error, dataという値を持つオブジェクトを返します。これが、上で加えたsuspenseExchangeの働きでloading = trueの状態がなくなり、代わりにPromiseをthrowするように変化します。

このコンポーネントを<React.Suspense>で囲めば通信完了後に再描画がかかり、Error Boundaryで囲めば通信エラー時の処理をそちらに委譲できるため、コンポーネント内部からloadingerrorという状態がなくなり、dataだけを扱えばよくなります。コードも分岐も少なくて気持ちいいです。

幸いというか私達のプロジェクトはまだ少ししかできていないので、通信に関わるコンポーネントは十個程度しかありません。Relayのために「外側」と「内側」に分けたコンポーネントをまた統合して、mutationの呼び方をUrqlに合わせて…といった作業を無心で行い、一日で全て移行完了できました。よかった!

Urqlのメリット

Suspense! Suspense!

はい、しつこいですね。

キャッシュが(Relayとは違った形で)賢い

UrqlはGraphQLクエリの実行結果をDocument Cachingという方式でキャッシュしています。これはqueryと引数の組に対応するレスポンスを全部キャッシュしておき、mutationのレスポンスに含まれる__typenameという属性を元にキャッシュしたデータを無効にするやり方です。現実的なやり方だと思います。Relayほど厳密でないですが、私達のように厳密なサーバーを作る手間がかけられないチームには向いています。

なお、公式が提供しているexchangeにより、RelayのようなGraph Cacheもできるようになるらしいです(試していません)。

ドキュメントが短くて十分

今の所、公式ドキュメントだけ読めば基本的にやりたいことは全部できています。一つ一つの項目が短くて読みやすいので助かっています。

モックを作るのが簡単

公式ドキュメントのTestingの項にあるとおりに作ったらテストとstorybookがサクッとできました。書いたコードも十分少なくて気に入りました。

ライブラリの調査は慎重に、時には大胆に!

最初からUrql使えば良かったじゃん、と思われた方もいるでしょう。そうですね!

開発メンバー募集中

より良いチームワークを生み出す

チームの創造力を高めるコラボレーションツール

製品をみる