GraphQL の Fragment でコンポーネントの見通しがよくなった話

vivit株式会社でフロントエンドエンジニアをしている関です。

新型コロナウイルス感染症(COVID-19)への対策として発出された緊急事態宣言の影響で、弊社も2ヶ月ほどリモートワークとなり、私も駆け足で自宅に作業環境を構築しました。買っちゃいましたL字デスク。

さて、弊社ではいくつかのプロダクトのフロントエンドに React(Next.js) + TypeScript を採用しており、バックエンドとの通信には GraphQL(Apollo) を採用しています。

今回はその中で、コンポーネント分割をする際の GraphQL Query の定義と取得したデータの受け渡しについて話をします。

ここからは GitHub GraphQL API v4 を使って説明していきます。

developer.github.com

アジェンダ

  • pages から components へのデータの受け渡し方法について
  • Fragment で分割する問題点
  • graphql-anywhere が凄い

前提

この記事で利用している各技術の詳細な説明は省きます。

pages から components へのデータの受け渡し方法について

Next.js でページを作成する際、コンポーネントcomponents ディレクトリに分割して置くのが一般的かと思いますが、 GraphQL API で取得したデータを TypeScript の型定義に則って子コンポーネントで利用する際にどうやろうというのが問題になり、やり方の候補がいくつか挙がりました。

1. コンポーネントが必要な項目の取得を全てページで行う

メリット

  • データの受け渡しがいたってシンプル

デメリット

// pages/index.tsx
const query = gql`
  query Index {
    search(query: "github", first: 10, type: REPOSITORY) {
      nodes {
        ... on Repository {
          id
          nameWithOwner
          updatedAt
        }
      }
    }
  }
`;

export default () => {
  const { data, loading } = useQuery(query);
  const nodes = data?.search.nodes || [];

  if (loading) return <>Loading...</>;

  return (
    <>
      <Head>
        <title>Segmented fragment</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <Repos repos={nodes} />
    </>
  );
};

ページ内で使う項目をすべてページ側で定義、取得しコンポーネントに渡しています。

これでは子コンポーネント内で使う詳細な項目の知識までページが持ってしまっているので、子コンポーネントの変更にページが引っ張られる形になってしまいます。密です。

2. コンポーネントが必要なデータの取得を自身で行う

メリット

デメリット

// components/Repos/index.tsx
const query = gql`
  query Index {
    search(query: "github", first: 10, type: REPOSITORY) {
      nodes {
        ... on Repository {
          id
          nameWithOwner
          updatedAt
        }
      }
    }
  }
`;

export const Repos = () => {
  const { data, loading } = useQuery(query);
  const repos = data?.search.nodes || [];

  const list = repos.map((repo) => (
    <tr key={repo.id}>
      <td>{repo.nameWithOwner}</td>
      <td>{repo.updatedAt}</td>
    </tr>
  ));

  return (
    <table>
      <thead>
        <tr>
          <th>Name</th>
          <th>Updated at</th>
        </tr>
      </thead>
      <tbody>{list}</tbody>
    </table>
  );
};

コンポーネント内に必要な項目を取得するクエリを書き、またそれを実行しています。

GraphQL の利点を正面からぶっ潰すパワーのある案です。

3. コンポーネントに必要なクエリを Fragment で記述、取得はページで行う

メリット

  • ページがコンポーネントに項目まで依存しない
  • データの取得回数も一度で済む

デメリット

  • そのままだとページが取得したデータをまるごとコンポーネントに渡すことになる
// components/Repos/index.graphql
fragment Repos on Query {
  search(query: "github", first: 10, type: REPOSITORY) {
    nodes {
      ...Nodes
    }
  }
}

fragment Nodes on Repository {
  id
  nameWithOwner
  updatedAt
}
// components/Repos/index.tsx
type Props = {
  result: ReposFragment;
};

export const Repos = ({ result }: Props) => {
  const repos = result.search.nodes as NodesFragment[];
  const list = repos.map((repo) => (
    <tr key={repo.id}>
      <td>{repo.nameWithOwner}</td>
      <td>{repo.updatedAt}</td>
    </tr>
  ));

  return (
    <table>
      <thead>
        <tr>
          <th>Name</th>
          <th>Updated at</th>
        </tr>
      </thead>
      <tbody>{list}</tbody>
    </table>
  );
};
// pages/index.graphql
query Index {
  ...Repos
}
// pages/index.tsx
export default () => {
  const { data, loading } = useIndexQuery();

  if (loading) return <>Loading...</>;

  return (
    <>
      <Head>
        <title>Segmented fragment</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <Repos result={data} />
    </>
  );
};

この例ではコンポーネントで使う項目を Fragment で定義し、そこから GraphQL Code Generator で生成された **Fragment型を props の型に定義しています。

※ Fragment について Queries and Mutations | GraphQL

話は逸れますが、コンポーネント名のディレクトリを切った中に index.tsxindex.graphql を分けて定義し、クエリの実行には自動生成された Hooks を使うことによりクエリとコードの切り分けを行っています。詳しい実装は最後に記載するサンプルプロジェクトで参照できます。

例示用に使っている GitHub GraphQL API v4スキーマを TypeScript の型定義に落とし込む都合上コンポーネント側のクエリがゴチャっていますが、こういう場合にゴチャッとした部分をコンポーネント側に閉じ込められるのもメリットと言えますね。

さて一見良さそうですが一つ難点があります。

Fragment で分割する問題点

滅茶苦茶 度々メルカリの記事やコードを参考にしているのですが、見かけた記事内でこのようなやりとりを見つけました。 (何ならこの記事のほとんどは以下の記事に書かれていました)

engineering.mercari.com

フロントエンドからのGraphQLクエリを最適化した by vvakame · Pull Request #134 · mercari/mtc2018-web

取得したデータをそれぞれのコンポーネントに丸々渡しているのはやはり気持ち悪いと思うようで、 graphql-anywhere という名前を出していました。

graphql-anywhere が凄い

www.npmjs.com

実際には filter という graphql-anywhere 内のユーティリティメソッドなのですが、 GraphQL Code Generator で同じく生成された gql タグ(下記の例では ReposFragmentDoc)を元に取得したデータをフィルタリングすることができます。

import { filter } from "graphql-anywhere";
import Head from "next/head";
import * as React from "react";
import { Repos } from "../components";
import { ReposFragment, ReposFragmentDoc, useIndexQuery } from "../graphql";

export default () => {
  const { data, loading } = useIndexQuery();

  if (loading) return <>Loading...</>;

  return (
    <>
      <Head>
        <title>Segmented fragment</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <Repos result={filter<ReposFragment>(ReposFragmentDoc, data)} />
    </>
  );
};

実は Apollo の公式ドキュメントでも graphql-anywhere を使ったフィルタリング方法が書かれています。 本稿では TypeScript のジェネリクスで動的に型を指定しています。

www.apollographql.com

実際に動作するプロジェクトを作成したので公開します。

github.com

動作には GITHUB_TOKEN という名前で OAuth トークンを環境変数に設定する必要があります。

ドキュメントに従って取得、設定をしてください。

developer.github.com

参考