🐕

GraphQL の Fragment Colocation を導入したら依存関係がスッキリしてクエリもコンポーネントも書きやすくなった

2022/12/11に公開

この記事は Money Forward Engineering 1 Advent Calendar 2022 11 日目の投稿です 🎄
昨日 10 日目は cabossoldir さんによる 『コードレビューのとき、私は何をレビューしているのか?』 でした。


🙈 TL;DR

  • Fragment Colocation とは、コンポーネントが必要とするデータを Fragment にまとめてコンポーネントと同じ場所に配置 (co-locate) すること
  • Fragment Colocation を導入することで、「Query や Mutation を実行するコンポーネント」と、「それらの結果を必要とするコンポーネント」との関心の分離ができる
  • Query, Mutation, Fragment はそれを実行するあるいは必要とするコンポーネントと同じファイル内に宣言すると依存関係が見やすく、変更がしやすくなる
  • Fragment を元に生成した型を Props の型として使用することで、Props の保守性を高く保てる
  • Query, Mutation, Fragments を元に生成する型や hooks は、 GraphQL Code Generator の near-operation-file-preset を使うことで、コンポーネントファイルと colocate でき、適切な単位にファイル分割することでバンドルサイズを減らすことが出来る

簡単な実装例はこちら 👇

https://github.com/taigakiyokawa/fragment-colocation-sample-app/pull/6


こんにちは、マネーフォワード クラウド会計のフロントエンドエンジニアの taigakiyokawa です。

年の瀬を感じる今日この頃ですが、いよいよ来週は M-1 グランプリ 2022 決勝戦ですね。今年もどの組が優勝するのか全く予想がつきません。自分は真空ジェシカ、ダイヤモンド、キュウ、ウエストランド、そしてヨネダ 2000 を応援しています。というかもはや全組優勝してほしいです。

さて、クラウド会計では現在、モノリシックな Rails アプリケーションからフロントエンドを分離するプロジェクトを進めており、フロントエンドに Next.js を使用し、バックエンドとのやり取りには GraphQL を使用しています。そこで今回は、 GraphQL の Fragment Colocation というコンセプトを導入した背景やメリット、実装方針を簡単な例と合わせて紹介していきたいと思います。

🍵 前提

対象読者

  • 下記使用技術の基礎的なことが分かる方
  • サーバーサイドとのやり取りに GraphQL を使用している、あるいは使用したいと思っているフロントエンドエンジニアの方
  • GraphQL はなんとなく扱えるけど、 Fragment をあまり使っていない、あるいは知らない方
  • GraphQL の Query や Mutation と、それらを使用する React Component との依存関係が見通しづらくなってきた方

使用技術

主な使用技術のバージョン
  • node: 18.12.1
  • typescript: 4.9.4
  • react: 18.2.0
  • next: 13.0.6
  • @apollo/client: 3.7.2
  • graphql: 16.6.0
  • GraphQL Code Generator:
    • @graphql-codegen/cli: 2.16.1
    • @graphql-codegen/near-operation-file-preset: 2.4.4
    • @graphql-codegen/typescript: 2.8.5
    • @graphql-codegen/typescript-operations: 2.5.10
    • @graphql-codegen/typescript-react-apollo: 3.3.7

🌃 背景

Fragment Colocation の導入前は、 GraphQL の Query や Mutation を src/graphql/ というディレクトリ以下に queries/FetchFooPage.graphqlmutations/UpdateBar.graphql のように、 React Component とは別のファイルを作成して宣言していました。

しかし、宣言している Query や Mutation が src/components/ にある各コンポーネントと離れているため、各コンポーネントが必要とするデータと src/graphql/ にある Query や Mutation との対応関係が見づらくなっていました。また、 Query や Mutation に変更を加えたい時にも、変更対象となるファイルが多く、無駄な工数が生じていました。

そこでより良い設計にリファクタリングしていく議論を進めていた中で、Fragment Colocation の提案があり、導入することにしました。

🐸 GraphQL の Fragment とは

GraphQL では、複数の Query や Mutation で共有したいフィールドをまとめて Fragment として宣言することができます。

https://graphql.org/learn/queries/#fragments

https://www.apollographql.com/docs/react/data/fragments

🦩 Fragment Colocation とは

Fragment とそのデータ使用するコンポーネントと同じ場所に配置 (co-locate) することを一般的に、 "Fragment Colocation""Colocating Fragments" と呼びます。

Fragment Colocation をすると何がうれしいのか

Fragment Colocation には主に以下のようなメリットがあると考えています。

  • 「Query や Mutation を実行するコンポーネント」と、「それらの結果を必要とするコンポーネント」との関心の分離ができること
  • Fragment のフィールドを編集することで、 Query を直接編集することなく取得したい値の更新ができること
  • Fragment を元に生成した型を Props の型として使用することで、Props の保守性を高く保てること
  • React Component と同じ場所に宣言することで、コンポーネントとクエリの依存関係が分かりやすくなること
  • GraphQL Code Generator による型や hooks の生成ファイルを、colocate した単位で分割することで、巨大な単一の生成ファイルを読み込む場合と比べてバンドルサイズを減らすことができること

詳しくは Quramy さんの GraphQL Workshop Chapter 3. コロケーション を読むとよく分かります。

https://github.com/Quramy/gql-study-workshop/tree/main/chapters

🧭 実装方針

チームで議論した結果、Fragment Colocation 導入後の実装方針として以下の事項を ADR にまとめました。

  • GraphQL の Query, Mutation, そして Fragment は React Components と同じファイルに宣言すること
  • Fragment の名前は <ComponentName>_<TypeName>[1] で宣言すること
  • GraphQL Code Generator の near-operation-file preset を使用して各コンポーネントディレクトリ下に型やカスタムフックを生成すること

🍛 具体例

以下のコンポーネントで構成される簡単なプロフィールページのようなものを使って、 Fragment Colocation 前後の比較をします。

  • ViewerPage: Query を実行するページコンポーネント
  • Profile: ユーザーの名前と自己紹介文を表示するコンポーネント
  • PostList: ユーザーの投稿一覧を表示するコンポーネント

Sample App Components Image

サンプルアプリケーションの全体像はこちら 👇
https://github.com/taigakiyokawa/fragment-colocation-sample-app

Schema

type-defs.ts
export const typeDefs = gql`
  type User {
    id: ID!
    name: String!
    bio: String
    posts: [Post!]!
  }

  type Post {
    id: ID!
    date: String!
    title: String!
  }

  type Query {
    viewer: User!
  }
`

Before Colocated Fragments

Correspondence between a query and components before using fragments

Before の状態の全体像はこちら 👇
https://github.com/taigakiyokawa/fragment-colocation-sample-app/tree/before-colocate-fragments

graphql/FetchViewerPage.graphql
FetchViewerPage.graphql
query FetchViewerPage {
  viewer {
    id
    name
    bio
    posts {
      id
      date
      title
    }
  }
}
components/ViewerPage.tsx
ViewerPage.tsx
import { useFetchViewerPageQuery } from '../../graphql/__generated__/graphql-types'

export const ViewerPage: FC = () => {
  const { data } = useFetchViewerQuery()

  if (!data) {
    <p>Loading...</p>
  }

  const {
    viewer: { name, bio, posts },
  } = data

  return (
    <div>
      <Profile name={name} bio={bio} />
      <PostList posts={posts} />
    </div>
  )
}
components/Profile.tsx
Profile.tsx
type Props = {
  name: string
  bio: string | null
}

export const Profile: FC<Props> = ({ name, bio }) => {
  return (
    <div>
      <h1>{name}</h1>
      <p>{bio}</p>
    </div>
  )
}
components/PostList.tsx
PostList.tsx
type Props = {
  posts: {
    id: string
    title: string
  }[]
}

export const PostList: FC<Props> = ({ posts }) => {
  return (
    <div>
      <h2>Posts:</h2>
      <ul>
        {posts.map(({ id, title }) => {
          <li key={id}>{title}</li>;
        })}
      </ul>
    </div>
  )
}
codegen.ts
codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli'

const config: CodegenConfig = {
  schema: 'graphql/type-defs.ts',
  generates: {
    'graphql/__generated__/graphql-types.ts': {
      documents: 'graphql/**/*.graphql',
      plugins: [
        'typescript',
        'typescript-operations',
        'typescript-react-apollo',
      ],
    },
  },
}
export default config

After Colocated Fragments

Correspondence between a query and components after using fragments

Before との差分はこちらの PR にまとめてあります 👇
https://github.com/taigakiyokawa/fragment-colocation-sample-app/pull/6

components/ViewerPage.tsx
ViewerPage.tsx
import { useFetchViewerPageQuery } from './__generated__'

gql`
  query FetchViewerPage {
    viewer {
      id
      ...Profile_User
      posts {
        ...PostList_Post
      }
    }
  }
`

export const ViewerPage: FC = () => {
  const { data } = useFetchViewerPageQuery()

  if (!data) {
    return <p>Loading...</p>
  }

  const {
    viewer: { name, bio, posts },
  } = data

  return (
    <div>
      <Profile name={name} bio={bio} />
      <PostList posts={posts} />
    </div>
  )
}

components/Profile.tsx
Profile.tsx
import { Profile_UserFragment } from './__generated__'

gql`
  fragment Profile_User on User {
    name
    bio
  }
`

type Props = Profile_UserFragment

export const Profile: FC<Props> = ({ name, bio }) => {
  return (
    <div>
      <h1>{name}</h1>
      <p>{bio}</p>
    </div>
  )
}
components/PostList.tsx
PostList.tsx
import { PostList_PostFragment } from './__generated__'

gql`
  fragment PostList_Post on Post {
    id
    date
    title
  }
`

type Props = {
  posts: PostList_PostFragment[]
}

export const PostList: FC<Props> = ({ posts }) => {
  return (
    <div>
      <h2>Posts</h2>
      <ul>
        {posts.map(({ id, date, title }) => {
          return (
            <li key={id}>
              {date}: {title}
            </li>
          )
        })}
      </ul>
    </div>
  )
}
codegen.ts
codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli'

const config: CodegenConfig = {
  schema: 'graphql/type-defs.ts',
  generates: {
    'graphql/__generated__/graphql-schema-types.ts': {
      plugins: ['typescript'],
    },
    'components/': {
      documents: 'components/**/index.tsx',
      preset: 'near-operation-file',
      plugins: ['typescript-operations', 'typescript-react-apollo'],
      presetConfig: {
        baseTypesPath: '../graphql/__generated__/graphql-schema-types.ts',
        folder: '__generated__',
        extension: '.ts',
        importTypesNamespace: 'SchemaTypes',
      },
    },
  },
}
export default config

Before/After の比較

Before Colocated Fragments After Colocated Fragments
Query コンポーネントと別ファイルに宣言 実行するコンポーネントと同じ場所に宣言
Fragment 未使用 そのデータを必要とするコンポーネントと同じ場所に宣言し、Query の組み立てに使用
Props graphql-codegen で生成される型とは別で定義 graphql-codegen で Fragment を元に生成した型を使用
新しくフィールドを追加したい場合 Query を直接更新して、 対応する Props も更新する 対象の Fragment を更新するのみ

今回はとても簡単なコンポーネントでの例を紹介しましたが、より大規模で複雑な実装になっていくにつれて、この差による効果はとても大きなものになります。

🎯 まとめ

  • Fragment Colocation とは、コンポーネントが必要とするデータを Fragment にまとめてコンポーネントと同じ場所に配置 (co-locate) すること
  • Fragment Colocation を導入することで、「Query や Mutation を実行するコンポーネント」と、「それらの結果を必要とするコンポーネント」との関心の分離ができる
  • Query, Mutation, Fragment はそれを実行するあるいは必要とするコンポーネントと同じファイル内に宣言すると依存関係が見やすく、変更がしやすくなる
  • Fragment を元に生成した型を Props の型として使用することで、Props の保守性を高く保てる
  • Query, Mutation, Fragments を元に生成する型や hooks は、 GraphQL Code Generator の near-operation-file-preset を使うことで、コンポーネントファイルと colocate でき、適切な単位にファイル分割することでバンドルサイズを減らすことが出来る

最後までお読みいただきありがとうございました。明日 12 日目は森田さんによる『Agile Testing Days 2022 に参加してきました!』です!お楽しみに〜 👋🏻 🎅🏻
https://adventar.org/calendars/7397


🔗 参考リンク

脚注
  1. Relay の命名規則 を参考にしています。 ↩︎

GitHubで編集を提案

Discussion