メインコンテンツへスキップ
バージョン: v18.0.0

接続とページネーション

このセクションでは、ページネーションされたリストや無限スクロールを含む、多数のアイテムのコレクションを処理する方法を説明します。Relayでは、ページネーションされたリストと無限スクロールされたリストは、Connectionとして知られる抽象化を使用して処理されます。


Relayは、ページネーションされたアイテムのコレクションを処理する際に多くの作業を代行します。しかし、そのためには、それらのコレクションがスキーマでどのようにモデル化されているかについて、特定の規約に依存しています。この規約は強力で柔軟性があり、多くの製品でアイテムのコレクションを構築してきた経験から生まれています。このスキーマ規約がなぜこのように機能するのかを理解するために、設計プロセスを段階的に見ていきましょう。

理解すべき3つの重要なポイントがあります。

  • エッジ自体にはプロパティがあります。たとえば、友達のリストでは、その人と友達になった日付は、あなた自身ではなく、あなたと相手の間のエッジのプロパティです。これは、エッジを表すノードを作成することで処理します。
  • リスト自体には、次のページが利用可能かどうかなど、プロパティがあります。これは、リスト自体を表すノードと現在のページのノードを使用して処理します。
  • ページネーションは、オフセットではなく、カーソル(次の結果ページを指す不透明な記号)によって行われます。

ユーザーの友達のリストを表示したいとしましょう。高いレベルでは、ビューアとその友達がそれぞれノードであるグラフを想像します。ビューアから各友達ノードへのエッジがあり、エッジ自体にはプロパティがあります。

Conceptual graph with properties on its edges

それでは、GraphQLを使用してこの状況をモデル化してみましょう。

GraphQLでは、エッジではなく、ノードのみにプロパティを持たせることができます。そのため、最初に、あなたとあなたの友達との概念的なエッジを、独自のノードで表します。

Edge properties modeled using nodes that represent the edges

これで、エッジのプロパティは、「FriendsEdge」と呼ばれる新しいタイプのノードで表されます。

これをクエリするGraphQLは次のようになります。

// XXX example only, not final code
fragment FriendsFragment1 on Viewer {
friends {
since // a property of the edge
node {
name // a property of the friend itself
}
}
}

これで、エッジが作成された日付(つまり、その人と友達になった日付)など、エッジ固有の情報をGraphQLスキーマに配置するのに適した場所ができました。


それでは、ページネーションと無限スクロールをサポートするために、スキーマでモデル化する必要があるものを考えてみましょう。

  • クライアントは、必要なページのサイズを指定できなければなりません。
  • クライアントは、さらにページがあるかどうかを知らされなければなりません。これにより、「次のページ」ボタンを有効または無効にしたり(または無限スクロールの場合、さらにリクエストを停止したり)することができます。
  • クライアントは、既に持っているページの次のページを要求できなければなりません。

これらのことを行うために、GraphQLの機能をどのように使用できるでしょうか?ページサイズの指定は、フィールド引数で行われます。つまり、単にfriendsではなく、クエリはfriends(first: 3)となり、ページサイズを引数としてfriendsフィールドに渡します。

サーバーが次のページがあるかどうかを伝えるには、エッジ自体に関する情報を格納するためにエッジごとにノードを導入するのと同じように、友達のリスト自体に関する情報を持つノードをグラフに導入する必要があります。この新しいノードはConnectionと呼ばれます。

Connectionノードは、あなたとあなたの友達の間の接続自体を表します。接続に関するメタデータはそこに保存されます。たとえば、totalCountフィールドがあり、友達の人数を示すことができます。さらに、常に現在のページを表す2つのフィールドがあります。1つは、次のページがあるかどうかなどの現在のページに関するメタデータを含むpageInfoフィールド、もう1つは、前に見たエッジを指すedgesフィールドです。

The full connection model with page info and edges

最後に、結果の次のページを要求する方法が必要です。上記の図では、PageInfoノードにlastCursorというフィールドがあることに注目してください。これは、サーバーによって提供される不透明なトークンであり、提供された最後のエッジ(友達の「Charmaine」)のリスト内の位置を表します。次に、このカーソルをサーバーに渡して、次のページを取得することができます。

lastCursor値をサーバーに引数としてfriendsフィールドに渡すことで、既に取得した友達のの友達をサーバーに要求できます。

After fetching the next page of results

ページネーションされたリストをモデル化するこの全体的なスキームは、GraphQLカーソル接続仕様で詳細に指定されています。これは多くの異なるアプリケーションに対して柔軟性があり、Relayはページネーションを自動的に処理するためにこの規約に依存していますが、Relayを使用するかどうかを問わず、このようにスキーマを設計することは良いアイデアです。

Connectionの基礎となるモデルを説明したので、今度は、Newsfeedのストーリーにコメントを実装するために実際にそれを使用することに注目しましょう。


「コメントの読み込み」の実装

Storyコンポーネントをもう一度見てください。インポートしてStoryの一番下に追加できるStoryCommentsSectionコンポーネントがあります。

import StoryCommentsSection from './StoryCommentsSection';

function Story({story}) {
const data = useFragment(StoryFragment, story);
return (
<Card>
<Heading>{data.title}</Heading>
<PosterByline person={data.poster} />
<Timestamp time={data.posted_at} />
<Image image={data.image} />
<StorySummary summary={data.summary} />
<StoryCommentsSection story={data} />
</Card>
);
}

そして、StoryCommentsSectionのフラグメントをStoryのフラグメントに追加します。

const StoryFragment = graphql`
fragment StoryFragment on Story {
// ... as before
...StoryCommentsSectionFragment
}
`;

npm run relayをもう一度実行して、新しいRelayアーティファクトを生成し、エディターのエラーを解消します。

この時点で、各ストーリーに最大3つのコメントが表示されるはずです。一部のストーリーには3つ以上のコメントがあり、これらには「もっと読み込む」ボタンが表示されますが、まだ接続されていません。

Screenshot of a story with the first three comments and a Load More button

StoryCommentsSectionに移動して見てみましょう。

import LoadMoreCommentsButton from "./LoadMoreCommentsButton";

const StoryCommentsSectionFragment = graphql`
fragment StoryCommentsSectionFragment on Story {
comments(first: 3) {
edges {
node {
...CommentFragment
}
}
pageInfo {
hasNextPage
}
}
}
`;

function StoryCommentsSection({story}) {
const data = useFragment(StoryCommentsSectionFragment, story);
const onLoadMore = () => {/* TODO */};
return (
<>
{data.comments.edges.map(commentEdge =>
<Comment comment={commentEdge.node} />
)}
{data.comments.pageInfo.hasNextPage && (
<LoadMoreCommentsButton onClick={onLoadMore} />
)}
</>
);
}

ここでは、StoryCommentsSectionがConnectionスキーマ規約を使用して各ストーリーの最初の3つのコメントを選択していることがわかります。commentsフィールドはページサイズを引数として受け入れ、各コメントにはedgeがあり、その中には実際のコメントデータを含むnodeがあります。ここではCommentFragmentを展開して、Commentコンポーネントを使用して個々のコメントを表示するために必要なデータを取得しています。また、接続のpageInfoフィールドを使用して、「もっと読み込む」ボタンを表示するかどうかを決定します。

したがって、私たちの仕事は、「もっと読み込む」ボタンによって追加のコメントページを実際に読み込むようにすることです。Relayは細かい点を処理しますが、設定する手順をいくつか提供する必要があります。

フラグメントの拡張

コンポーネントを変更する前に、フラグメント自体に3つの追加情報が必要です。まず、ハードコードするのではなく、ページサイズとカーソルをフラグメント引数としてフラグメントが受け入れるようにする必要があります。

const StoryCommentsSectionFragment = graphql`
fragment StoryCommentsSectionFragment on Story
@argumentDefinitions(
cursor: { type: "String" }
count: { type: "Int", defaultValue: 3 }
)
{
comments(after: $cursor, first: $count) {
edges {
node {
...CommentFragment
}
}
pageInfo {
hasNextPage
}
}
}
`;

次に、フラグメントを再取得可能にする必要があります。これにより、Relayは引数(つまり、$cursor引数の新しいカーソル)の新しい値で再度フェッチできるようになります。

const StoryCommentsSectionFragment = graphql`
fragment StoryCommentsSectionFragment on Story
@refetchable(queryName: "StoryCommentsSectionPaginationQuery")
@argumentDefinitions(
... as before
`;

これで、フラグメントに対して行う必要がある変更はあと1つだけになりました。Relayは、ページネーションを行うConnectionを表すフラグメント内のどのフィールドを知る必要があります。そのため、@connectionディレクティブでマークします。

const StoryCommentsSectionFragment = graphql`
fragment StoryCommentsSectionFragment on Story
@refetchable(queryName: "StoryCommentsSectionPaginationQuery")
@argumentDefinitions(
cursor: { type: "String" }
count: { type: "Int", defaultValue: 3 }
)
{
comments(after: $cursor, first: $count)
@connection(key: "StoryCommentsSectionFragment_comments")
{
edges {
node {
...CommentFragment
}
}
pageInfo {
hasNextPage
}
}
}
`;

@connectionディレクティブにはkey引数が必要です。これは一意の文字列でなければなりません。ここでは、フラグメント名とフィールド名から形成されています。このキーは、次の章で説明するように、ミューテーション中に接続の内容を編集するときに使用されます。

usePaginationFragmentフック

フラグメントをすべて準備したので、コンポーネントを変更して「もっと読み込む」ボタンを実装できます。

StoryCommentsSectionコンポーネントの一番上にある次の2行を

const data = useFragment(StoryCommentsSectionFragment, story);
const onLoadMore = () => {/* TODO */};

これらで置き換えます。

const {data, loadNext} = usePaginationFragment(StoryCommentsSectionFragment, story);
const onLoadMore = () => loadNext(3);

npm run relayを実行します。これで、「もっと読み込む」ボタンをクリックすると、さらに3つのコメントが読み込まれるようになります。

useTransitionを使用した読み込みエクスペリエンスの改善

現状では、「もっと読み込む」ボタンをクリックしても、新しいコメントの読み込みが完了して表示されるまで、ユーザーフィードバックはありません。すべてのユーザーアクションは即時フィードバックをもたらす必要があります。そのため、新しいデータの読み込み中はスピナーを表示しましょう。ただし、既存のUIを非表示にする必要はありません。

そのためには、Reactトランジション内でloadNextへの呼び出しをラップする必要があります。必要な変更は次のとおりです。

function StoryCommentsSection({story}) {
const [isPending, startTransition] = useTransition();
const {data, loadNext} = usePaginationFragment(StoryCommentsSectionFragment, story);
const onLoadMore = () => startTransition(() => {
loadNext(3);
});
return (
<>
{data.comments.edges.map(commentEdge =>
<Comment comment={commentEdge.node} />
)}
{data.comments.pageInfo.hasNextPage && (
<LoadMoreCommentsButton
onClick={onLoadMore}
disabled={isPending}
/>
)}
{isPending && <CommentsLoadingSpinner />}
</>
);
}

即時ではない結果をもたらすすべてのユーザーアクションは、Reactトランジションでラップする必要があります。これにより、Reactは異なる更新を優先できます。たとえば、データが利用可能になり、Reactが新しいコメントをレンダリングしているときに、ユーザーが別のタブをクリックして別のページに移動した場合、Reactは新しいコメントのレンダリングを中断して、ユーザーが希望する新しいページをレンダリングできます。


無限スクロールニュースフィード ストーリー

ページネーションについて学んだことを利用して、無限スクロールニュースフィードを作成しましょう。ニュースフィードは、コメントの読み込みを追加するのとほぼ同じですが、ボタンを押す代わりに、ユーザーがページの一番下までスクロールするとloadNextが自動的にトリガーされる点が異なります。

ステップ1 - クエリで接続フィールドを選択

現在、私たちのアプリはtopStoriesルートフィールドを使用して、上位3つのストーリーの単純な配列を取得しています。スキーマはViewer上にnewsfeedStoriesフィールドも提供しており、これはConnectionです。この新しいフィールドを使用するようにNewsfeedコンポーネントを変更しましょう。もう一度Newsfeed.tsxを見てください - 上部のGraphQLクエリは次のようになります。

const NewsfeedQuery = graphql`
query NewsfeedQuery {
topStories {
id
...StoryFragment
}
}
`;

これを次のように置き換えてください。

const NewsfeedQuery = graphql`
query NewsfeedQuery {
viewer {
newsfeedStories(first: 3) {
edges {
node {
id
...StoryFragment
}
}
}
}
}
`;

ここでは、topStoriesviewernewsfeedStoriesに置き換え、first引数を追加して、最初に上位3つのストーリーを取得しています。その中で、edge、そしてnodeを選択しています。これはStoryノードなので、以前と同じStoryFragmentを展開できます。また、Reactのkey属性として使用できるようにidも選択します。

ヒント

簡潔にするためにtopStorytopStoriesフィールドをQueryの最上位に配置しましたが、ページまたはアプリを見ている人に関連するフィールドはviewerというフィールドの下に配置するのが慣例です。実際のアプリで使用されるように、この慣例に切り替えます。

ステップ2 - Connectionのエッジをマップする

エッジをマップし、各ノードをレンダリングするようにNewsfeedコンポーネントを変更する必要があります。

function Newsfeed() {
const data = useLazyLoadQuery(NewsfeedQuery, {});
const storyEdges = data.viewer.newsfeedStories.edges;
return (
<>
{storyEdges.map(storyEdge =>
<Story key={storyEdge.node.id} story={storyEdge.node} />
)}
</>
);
}

ステップ3 - Newsfeedをフラグメントに分割する

Relayのページネーション機能は、クエリ全体ではなく、フラグメントでのみ機能します。これは、この単純なサンプルアプリではコンポーネント内で直接クエリを実行していますが、実際のアプリケーションでは、クエリは通常、高レベルのルーティングコンポーネントで実行され、ページネーションされたリストを表示しているコンポーネントと同じであることはめったにないためです。

これを機能させるには、NewsfeedQueryの内容をNewsfeedContentsFragmentというフラグメントに分離するだけです。

const NewsfeedQuery = graphql`
query NewsfeedQuery {
...NewsfeedContentsFragment
}
`;

const NewsfeedContentsFragment = graphql`
fragment NewsfeedContentsFragment on Query {
viewer {
newsfeedStories {
edges {
node {
id
...StoryFragment
}
}
}
}
}
`;

ここで、すべてのGraphQLスキーマには、クエリで使用可能な最上位のフィールドを表すQueryという型が含まれていることを述べておきます。on Queryフラグメントを定義することにより、それを最上位に直接展開できます。

Newsfeed内では、useLazyLoadQueryuseFragmentの両方を使用できますが、実際にはこれらは通常、異なるコンポーネントにあります。

export default function Newsfeed() {
const queryData = useLazyLoadQuery<NewsfeedQueryType>(NewsfeedQuery, {});
const data = useFragment(NewsfeedContentsFragment, queryData);
const storyEdges = data.newsfeedStories.edges;
...
}

ステップ4 - ページネーションのためにフラグメントを増強する

ストーリーにConnectionフィールドを使用し、フラグメントを作成したので、ページネーションをサポートするために必要なフラグメントに変更を加えることができます。これらは最後の例と同じです。次が必要です。

  • ページサイズとカーソル(firstafter)のフラグメント引数を追加します。
  • これらの引数をフィールド引数としてnewsfeedStoriesフィールドに渡します。
  • フラグメントを@refetchableとしてマークします。
  • newsfeedStoriesフィールドを@connectionでマークします。

最終的には、次のようになります。

const NewsfeedContentsFragment = graphql`
fragment NewsfeedContentsFragment on Query
@argumentDefinitions (
cursor: { type: "String" }
count: { type: "Int", defaultValue: 3 }
)
@refetchable(queryName: "NewsfeedContentsRefetchQuery")
{
viewer {
newsfeedStories(after: $cursor, first: $count)
@connection(key: "NewsfeedContentsFragment_newsfeedStories")
{
edges {
node {
id
...StoryFragment
}
}
}
}
}
`;

ステップ5 - usePaginationFragmentを呼び出す

これで、Newsfeedコンポーネントを修正してusePaginationFragmentを呼び出す必要があります。

function Newsfeed() {
const queryData = useLazyLoadQuery<NewsfeedQueryType>(
NewsfeedQuery,
{},
);
const {data, loadNext} = usePaginationFragment(NewsfeedContentsFragment, queryData);
const storyEdges = data.viewer.newsfeedStories.edges;
return (
<div className="newsfeed">
{storyEdges.map(storyEdge =>
<Story key={storyEdge.node.id} story={storyEdge.node} />
)}
</div>
);
}

ステップ6 - スクロールトリガーでページネーションを行う

ページの一番下が到達されたことを検出するInfiniteScrollTriggerというコンポーネントを用意しました。これを使用して、適切なタイミングでloadNextを呼び出すことができます。さらにページが存在するかどうか、現在次のページを読み込んでいるかどうかを知る必要があります。これらはusePaginationFragmentの戻り値から取得できます。

import InfiniteScrollTrigger from "./InfiniteScrollTrigger";

function Newsfeed() {
const queryData = useLazyLoadQuery<NewsfeedQueryType>(
NewsfeedQuery,
{},
);
const {
data,
loadNext,
hasNext,
isLoadingNext,
} = usePaginationFragment(NewsfeedContentsFragment, queryData);
function onEndReached() {
loadNext(1);
}
const storyEdges = data.viewer.newsfeedStories.edges;
return (
<div className="newsfeed">
{storyEdges.map(storyEdge =>
<Story key={storyEdge.node.id} story={storyEdge.node} />
)}
<InfiniteScrollTrigger
onEndReached={onEndReached}
hasNext={hasNext}
isLoadingNext={isLoadingNext}
/>
</div>
);
}

これで、ページの一番下までスクロールして、さらにストーリーが読み込まれるようになりました。本当のニュースフィードアプリのようです!


概要

  • Connectionは、ページネーション可能なリストの動作をモデル化するためにRelayが依存するスキーマの規約です。
  • 一般的に、単純なリストではなく、スキーマでConnectionを使用することをお勧めします。これにより、必要に応じてページネーションを行う柔軟性が得られます。

次に、サーバー上のデータの更新方法について最終的に見ていきます。Connectionはそこで役割を果たし、新しく作成されたノードを既存のConnectionに追加する方法を見ていきます。