🧩

GraphQLのFragment Colocationで実現する独立したコンポーネント開発とデータ取得問題の解決

に公開

この記事は、2025年8月28日に開催された「TSKaigi Mashup」での登壇内容を元に書き下ろしたものです。

https://speakerdeck.com/anji0114/full-stacknaxing-an-quan-wozhi-erugraphqlnofragmenthuo-yong

本記事では、GraphQLの設計パターンであるFragment Colocationが、コンポーネント開発における「データ取得の置き場所」や「親子間の密な依存」といった、よくある課題をどのように解決するのかを解説します。

この記事で書くこと

  • GraphQLの Fragment Colocation という設計パターンの概要
  • コンポーネント開発でよくある課題
    • 「どこでデータを取得するか問題」
    • 親子間依存や重複リクエスト
  • Fragment Colocationを用いた実装例と、既存のパターンとの比較
  • Fragment Colocationによって得られるメリット
    • コンポーネントの独立性向上
    • データ取得の最小化
    • 型安全の担保とメンテナンス性の向上

対象読者

  • GraphQLを使ってフロントエンド開発をしている方
  • 「どこでデータを取得すべきか」「親子間での依存が複雑になる」といった課題を感じている方
  • Fragment Colocationというパターンをまだ導入していない、または概要を知りたい方

GraphQL開発の基本的な流れ

本題に入る前に、GraphQLを使った開発の基本的な流れを簡単に説明します。

1. スキーマ定義

まず、バックエンドでAPIの仕様を「スキーマ」として定義します。typeでデータの型を、QueryやMutationでデータ操作(取得や更新)の方法を定義します。QueryはRESTにおけるエンドポイントのような役割を持ちます。

type Product {
  id: ID!
  name: String!
  drawingNumber: String!
  description: String!
  createdAt: String!
  updatedAt: String!
}

input GetProductInput {
  id: ID!
  tenantId: ID!
}

type Query {
  getProduct(input: GetProductInput!): Product!
}

2. バックエンド実装

次に、定義したスキーマに基づいて、バックエンドで処理を実装します。これは、特定のクエリ(例: getProduct)が呼ばれた際に、実際にどのようなデータを返すかを記述するロジックです。

export const getProductIdQuery = middy<Event, Return>()
  .use(handleError<Return>("get product failed"))
  .use(validate<Event>(eventSchema))
  .handler(async (event: Event): Promise<Return> => {
    const result = await productService.get(event.args.input);
    
    return {
      data: result,
    };
  });

3. フロントエンドでの型生成 (Codegen)

スキーマとフロントエンドで記述したクエリを元に、GraphQL Codegenというツールを使ってフロントエンドで利用する型定義や関数を自動生成します。npx graphql-codegenのようなコマンドを実行します。

export type Product = {
  __typename?: 'Product';
  id: Scalars['ID']['output'];
  name: Scalars['String']['output'];
  description: Scalars['String']['output'];
  drawingNumber: Scalars['String']['output'];
  createdAt: Scalars['String']['output'];
  updatedAt: Scalars['String']['output'];
};

export type GetProductInput = {
  id: Scalars['ID']['input'];
  tenantId: Scalars['ID']['input'];
};

export type Query = {
  __typename?: 'Query';
  getProduct: Product;
};

生成されるファイル

src/generated/gql/
├── consts.ts // Enumを定数化
├── fragment-masking.ts // Fragment型支援
├── gql.ts // GraphQL関数
├── graphql.ts // schemaの型定義
└── index.ts

コンポーネント指向開発におけるデータ取得の課題

ここまでで、Codegenによってフロントエンドに型が生成されました。
ここから、実際にフロントで開発をしていると、「このデータ、どこで取得すれば良いのだろう?」と考えることがあると思います。

例えば、ユーザー情報を取得するuseUserというカスタムフックがあったとします。

ケース1:コンポーネントごとに欲しいデータが違う

親コンポーネントではIDだけ、子コンポーネントでは詳細なプロフィール情報が欲しい、という状況です。

// 親コンポーネント
const Parent = ({ profileUserId }: Props) => {
  const user = useUser(); 
  const isMyProfile = user.id === profileUserId;

  return (
    <div>
      {isMyProfile && <EditProfileButton />}
      <UserProfile />
    </div>
  );
};

// 子コンポーネント
const UserProfile = () => {
  const user = useUser(); // name, email, avatar も欲しい
  return <div>{user.name}</div>;
};

この場合、useUserの実装は両方のコンポーネントの要求を満たすため、全フィールドを取得することになりがちです。結果として、Parentでは不要なデータを取得する オーバーフェッチが発生します。また、両方のコンポーネントでフックを呼ぶと、重複リクエストにも繋がります。

ケース2:コンポーネント階層の各所で呼ばれる

あるいは、データを表示するコンポーネントが階層の奥深くにある場合です。

// 階層1: Page.tsx
const Page = () => {
  // ページの閲覧権限チェックのためにuseUserを呼ぶ
  const { data, loading } = useUser();

  useEffect(()=> {
    //
  }, [data])

  if (loading) return <Spinner />;

  return <SomeDeeplyNestedComponent />;
};

// ...
// (中間のコンポーネント)
// ...

// 階層5: UserProfile.tsx
const UserProfile = () => {
  const { data } = useUser();
  return <div>{data.user.name}</div>;
};

// 階層6: UserAvatar.tsx
const UserAvatar = () => {
  const { data } = useUser();
  return <img src={data.user.avatarUrl} />;
};

どのコンポーネントがuserデータを必要とするか分からず、各階層でuseUserを呼んでしまうと、これもまた重複リクエストの温床となります。
これらの課題は、アプリケーションの規模が大きくなるほど顕著になります。

データ取得パターンと残存する課題

先ほどのデータ取得の課題に対して、よく用いられる代表的な設計パターンとして2つ例に挙げてみます。

Repository + Hooks パターン

データ取得ロジックを「Repository層」として分離し、UIコンポーネントはカスタムフック経由でデータを取得するパターンです。

// repository/userRepo.ts
// データ取得ロジックをカプセル化
export const getUser = async () => {
  return client.query({ query: GetUser });
};

// hooks/useUser.ts
// UIが利用するカスタムフック
export const useUser = () => {
  return useQuery(GetUser);
};

// components/UserProfile.tsx
const UserProfile = () => {
  // 結局「どこで呼ぶか」の問題が残る
  const user = useUser(); 
  return <div>{user.name}</div>;
};

このパターンは、データソースへのアクセスを一元管理できるメリットがあります。しかし、ケース2で挙げた 「どこでフックを呼ぶか?」という問題は解決されません。

  • 親で呼ぶのか、子で呼ぶのか?
  • 複数のコンポーネントで呼んだ場合、リクエストは重複しないか?
  • 子のコンポーネントが追加でフィールドを必要とした場合、親のクエリも修正する必要があるのではないか?

このように、コンポーネントの依存関係や設計に関する判断コストは依然として残ります。

Container/Presentational パターン

関心の分離を目的とした、Container/Presentationalパターンです。これは、データ取得や状態管理のロジックを「Containerコンポーネント」に担わせ、UIの表示のみに責任を持つ「Presentationalコンポーネント」にデータを渡す設計です。

// UserProfileContainer.tsx (データ取得)
const UserProfileContainer = ({ userId }) => {
  const { data, loading } = useQuery(GET_USER_QUERY, {
    variables: { userId }
  });

  if (loading) return <Spinner />;
  return <UserProfileView user={data.user} />;
};

// UserProfileView.tsx (表示のみ)
const UserProfileView = ({ user }) => (
  <div>{user.name}</div>
);

このパターンは責務が明確に分離され、Presentationalコンポーネントの再利用性が高まるという利点があります。しかし、新たな設計課題も生まれます。

  • どの粒度でContainerを作るべきか?
    • ページ単位か、セクション単位か、あるいはもっと小さなコンポーネント単位か。最適な粒度を見極める必要があります。
  • UI変更の煩雑さ
    • Presentationalコンポーネントで新しいフィールドが必要になると、結局Containerコンポーネントのクエリを修正する必要があります。UIの変更が常にデータ取得ロジックの変更を伴い、大規模なアプリケーションでは管理が複雑化します。

これらのパターンは課題解決の一助とはなるものの、コンポーネントの独立性を完全に保ち、データ取得の判断コストをゼロにすることは難しいです。

GraphQL Fragment Colocationを使用することで

ここからが本題です。
これまで挙げた「データ取得の置き場所」や「コンポーネント間の密な依存」といった課題は、GraphQLのFragment Colocationという設計パターンを用いて解決できます。

Fragment Colocationとは、各コンポーネントが必要とするデータのフラグメント(断片)を、そのコンポーネント自身に定義する設計パターンです。

1. 子コンポーネントが「Fragment」を定義する

各子コンポーネントが、自身が表示に必要なデータだけをfragmentとして宣言します。

// ファイル1: UserProfile.tsx
export const UserProfileFragment = gql`
  fragment UserProfileFragment on User { name avatarUrl }
`;

type Props1 = { user: FragmentType<typeof UserProfileFragment> };

export const UserProfile: React.FC<Props1> = ({ user: userRef }) => {
  const u = useFragment(UserProfileFragment, userRef);
  return (
    <div>
      <img src={u.avatarUrl} />
      {u.name}
    </div>
  );
};

// ファイル2: UserIdBadge.tsx
export const UserIdBadgeFragment = gql`
  fragment UserIdBadgeFragment on User { id }
`;

type Props2 = { user: FragmentType<typeof UserIdBadgeFragment> };

export const UserIdBadge: React.FC<Props2> = ({ user: userRef }) => {
  const u = useFragment(UserIdBadgeFragment, userRef);
  return <code>{u.id}</code>;
};

各コンポーネントは、他のコンポーネントを気にすることなく、自身が必要なデータだけをfragmentとして定義します。

2. 親コンポーネントがFragmentを束ねてクエリを実行する

親コンポーネントは、子コンポーネントで定義したFragmentをスプレッド構文...で一つのクエリにまとめます。

// Page.tsx
const GET_USER = gql`
  query GetUser {
    user {
      ...UserProfileFragment
      ...UserIdBadgeFragment
    }
  }
`;

export const Page: React.FC = () => {
  const { data } = useQuery(GET_USER); // ← 取得はここ1箇所
  return (
    <>
      <UserProfile user={data.user} />
      <UserIdBadge user={data.user} />
    </>
  );
};

これによって、子コンポーネントは親の取得場所を一切気にせず、自分のFragmentだけ書けば開発できます。

結果として、重複リクエストは消え、過不足のある取得も最小限に抑えられます。
さらに、宣言していないフィールドにアクセスしようとすると型エラーが発生するため、型安全も担保されます。

Full Stackな型安全を支えるFragment Colocation

Fragment Colocationがもたらす型安全について、バックエンドのスキーマ変更を例に解説します。

1. フロントエンドの実装

PostCardコンポーネントで、投稿のタイトルと著者の情報のFragmentを定義します。

import Image from "next/image";
import { FragmentType, graphql, useFragment } from "@/generated/gql";

const fragment = graphql(`
  fragment PostCardFragment on Post {
    id
    title
    author {
      id
      name
      avatar
    }
  }
`);

type PostCardProps = { post: FragmentType<typeof fragment> };

export const PostCard = ({ post: _post }: PostCardProps) => {
  const post = useFragment(fragment, _post);
  
  return (
    <div>
      <h3>{post.title}</h3>
      <div>
        <Image
          src={post.author.avatar || ""}
          alt={post.author.name}
          width={24}
          height={24}
        />
        <div>{post.author.name}</div>
      </div>
    </div>
  );
};

親のPostListコンポーネントは、このFragmentを使って投稿一覧を取得します。

import { useQuery } from "@apollo/client";
import { graphql } from "@/generated/gql";
import { GetPostsDocument } from "@/generated/gql/graphql";
import { PostCard } from "./PostCard";

graphql(`
  query GetPosts {
    getPosts {
      id
      ...PostCardFragment
    }
  }
`);

export const PostList = () => {
  const { data } = useQuery(GetPostsDocument);
  return (
    <div>
      {data?.getPosts.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
};

2. バックエンドのスキーマ変更

バックエンドのAPI仕様が変更され、GraphQLスキーマが以下のように変わったとします。User 型の avatarフィールドがavatarUrlにリネームされました。

# schema.graphql (変更前)
type User {
  id: ID!
  name: String!
  avatar: String
}

# schema.graphql (変更後)
type User {
  id: ID!
  name: String!
  avatarUrl: String # 変更点
}

3. フロントエンドでの型エラー検知

このスキーマ変更後にフロントエンドで graphql-codegenを実行すると、avatarフィールドを参照しているPostCardコンポーネントでのみ、コンパイルエラーが発生します。

ターミナルにも、どのファイルのどの場所でエラーが起きているかが明確に示されます。

このように、型エラーをコンポーネントレベルでピンポイントに検出できます。
また、修正が必要なのはPostCard.tsxだけであり、その親であるPostList.tsxのクエリや実装には一切変更が不要です。

コンポーネントが増えた場合でも...

例えば、UserInfoやCommentUserなど、複数のコンポーネントがavatarを利用していたとしても、エラーが出るのはそれらのコンポーネントのみです。そのエラーに従って該当コンポーネントを一つずつ対応するだけで、他のコンポーネントへの予期せぬ影響を心配することなく、安全に修正できます。

まとめ

本記事では、GraphQLのFragment Colocationが、コンポーネント開発におけるデータ取得の課題をどのように解決するのかを解説しました。
まとめると、Fragment Colocationには以下のメリットがあります。

  • 従来の「どこでクエリを呼ぶか?」「親子で重複してない?」という判断コストが減る。
  • 各コンポーネントが必要なデータをFragmentで宣言するため、 親の設計や他コンポーネントの実装を知る必要がない。
  • バックエンド側でSchemaを変更時、コンポーネントレベルでコンパイルエラーを出してくれる。

これらのメリットにより、コンポーネントの独立性が高まり、アプリケーション全体のメンテナンス性向上に繋がります。
現在GraphQLを用いた開発で、本記事で挙げたような課題に直面している方の、解決のヒントになれば幸いです。

最後に

匠技研工業では、エンジニアを積極的に募集しています。
本記事で解説したGraphQLやTypeScriptを用いたFull Stackな開発に加え、CursorやClaude Codeといったツールを導入し、開発の生産性向上にも積極的に取り組んでいます。
興味を持たれた方は、ぜひお気軽にご連絡ください。

https://takumi-giken.notion.site/1f1d2b290a60808cbe61f1bc38a195fe

Discussion