接続とページネーション
このセクションでは、ページネーションされたリストや無限スクロールを含む、多数のアイテムのコレクションを処理する方法を説明します。Relayでは、ページネーションされたリストと無限スクロールされたリストは、Connectionとして知られる抽象化を使用して処理されます。
Relayは、ページネーションされたアイテムのコレクションを処理する際に多くの作業を代行します。しかし、そのためには、それらのコレクションがスキーマでどのようにモデル化されているかについて、特定の規約に依存しています。この規約は強力で柔軟性があり、多くの製品でアイテムのコレクションを構築してきた経験から生まれています。このスキーマ規約がなぜこのように機能するのかを理解するために、設計プロセスを段階的に見ていきましょう。
理解すべき3つの重要なポイントがあります。
- エッジ自体にはプロパティがあります。たとえば、友達のリストでは、その人と友達になった日付は、あなた自身ではなく、あなたと相手の間のエッジのプロパティです。これは、エッジを表すノードを作成することで処理します。
- リスト自体には、次のページが利用可能かどうかなど、プロパティがあります。これは、リスト自体を表すノードと現在のページのノードを使用して処理します。
- ページネーションは、オフセットではなく、カーソル(次の結果ページを指す不透明な記号)によって行われます。
ユーザーの友達のリストを表示したいとしましょう。高いレベルでは、ビューアとその友達がそれぞれノードであるグラフを想像します。ビューアから各友達ノードへのエッジがあり、エッジ自体にはプロパティがあります。
それでは、GraphQLを使用してこの状況をモデル化してみましょう。
GraphQLでは、エッジではなく、ノードのみにプロパティを持たせることができます。そのため、最初に、あなたとあなたの友達との概念的なエッジを、独自のノードで表します。
これで、エッジのプロパティは、「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
フィールドです。
最後に、結果の次のページを要求する方法が必要です。上記の図では、PageInfo
ノードにlastCursor
というフィールドがあることに注目してください。これは、サーバーによって提供される不透明なトークンであり、提供された最後のエッジ(友達の「Charmaine」)のリスト内の位置を表します。次に、このカーソルをサーバーに渡して、次のページを取得することができます。
lastCursor
値をサーバーに引数としてfriends
フィールドに渡すことで、既に取得した友達の後の友達をサーバーに要求できます。
ページネーションされたリストをモデル化するこの全体的なスキームは、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つ以上のコメントがあり、これらには「もっと読み込む」ボタンが表示されますが、まだ接続されていません。
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
}
}
}
}
}
`;
ここでは、topStories
をviewer
のnewsfeedStories
に置き換え、first
引数を追加して、最初に上位3つのストーリーを取得しています。その中で、edge
、そしてnode
を選択しています。これはStory
ノードなので、以前と同じStoryFragment
を展開できます。また、Reactのkey
属性として使用できるようにid
も選択します。
簡潔にするためにtopStory
とtopStories
フィールドを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
内では、useLazyLoadQuery
とuseFragment
の両方を使用できますが、実際にはこれらは通常、異なるコンポーネントにあります。
export default function Newsfeed() {
const queryData = useLazyLoadQuery<NewsfeedQueryType>(NewsfeedQuery, {});
const data = useFragment(NewsfeedContentsFragment, queryData);
const storyEdges = data.newsfeedStories.edges;
...
}
ステップ4 - ページネーションのためにフラグメントを増強する
ストーリーにConnectionフィールドを使用し、フラグメントを作成したので、ページネーションをサポートするために必要なフラグメントに変更を加えることができます。これらは最後の例と同じです。次が必要です。
- ページサイズとカーソル(
first
とafter
)のフラグメント引数を追加します。 - これらの引数をフィールド引数として
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に追加する方法を見ていきます。