ミューテーションと更新
この章では、サーバーとクライアントのデータを更新する方法を学びます。主に2つの例を紹介します。
- ニュースフィードストーリーの「いいね」ボタンの実装
- ニュースフィードストーリーにコメントを投稿する機能の実装
データの更新は複雑な問題領域であり、Relayは多くの側面を自動的に処理しますが、発生する可能性のあるケースをアプリができるだけ堅牢に処理できるように、手動で制御することもできます。
まず、2つの用語を区別しましょう。
- ミューテーションとは、サーバー上のデータを変更する何らかのアクションを実行するようにサーバーに要求することです。これは、HTTP POSTに類似したGraphQLの機能です。
- 更新とは、Relayのローカルクライアント側データストアを変更することです。
クライアントは、サーバー側の個々のデータを直接操作することはできません。むしろ、ミューテーションは、ユーザーが誰かと友達になった、グループに参加した、コメントを投稿した、特定のニュースフィードストーリーに「いいね」をした、誰かをブロックした、コメントを削除したなど、ユーザーの意図を表す不透明な高レベルの要求です。(GraphQLスキーマは、使用可能なミューテーションと、各ミューテーションが受け入れる入力パラメータを定義します。)
ミューテーションは、グラフの状態に広範囲に及ぶ予測不可能な影響を与える可能性があります。たとえば、グループに参加したとします。多くのことが変化する可能性があります
- あなたの名前がグループメンバーリストに追加されます
- グループのメンバー数が1つ増えます
- グループがあなたのグループリストに追加されます
- メンバー限定の投稿がグループの投稿フィードに表示されます
- おすすめのグループが変更される場合があります
- グループの管理者に通知が届く場合があります
- ロギング、モデルのトレーニング、メールの送信など、ユーザーには見えないその他の多くの影響
- など、など、など
一般的に、ミューテーションの完全なダウンストリーム効果を知ることは不可能です。そのため、サーバーにミューテーションの実行を依頼した後、クライアントはできる限りデータを整合性を保ちながら、ローカルデータストアを更新するために最善を尽くす必要があります。これは、ミューテーションレスポンスの一部としてサーバーに特定の更新データを要求することと、ストアを整合性を保つために修正する_updater_と呼ばれる命令型コードによって行われます。
すべてのケースを網羅する原理的な解決策はありません。ミューテーションがグラフに与える影響を完全に知ることができたとしても、更新されたデータをすぐに表示したくない状況さえあります。たとえば、誰かのプロフィールページにアクセスしてブロックした場合、そのページに表示されているすべてがすぐに消えることを望みません。どのデータを更新するかは、最終的にはUI設計上の決定です。
Relayは、ミューテーションに応じてデータをできるだけ簡単に更新できるようにします。たとえば、特定のコンポーネントを更新する場合、そのフラグメントをミューテーションに展開できます。これにより、そのフラグメントによって選択されたものの更新データを送信するようにサーバーに要求します。その他のケースでは、Relayのローカルデータストアを命令的に変更する_updater_を手動で記述する必要があります。以下では、これらのすべてのケースについて説明します。
「いいね」ボタンの実装
まずは、ニュースフィードストーリーの「いいね」ボタンを実装してみましょう。幸いなことに、「いいね」ボタンはすでに用意されているので、Story.tsx
を開いて、Story
コンポーネントにドロップし、そのフラグメントをStoryのフラグメントに展開することを忘れないでください。
import StoryLikeButton from './StoryLikeButton';
...
const StoryFragment = graphql`
fragment StoryFragment on Story {
title
summary
// ... etc
...StoryLikeButtonFragment
}
`;
...
export default function Story({story}: Props) {
const data = useFragment(StoryFragment, story);
return (
<Card>
<PosterByline person={data.poster} />
<Heading>{data.title}</Heading>
<Timestamp time={data.posterAt} />
<Image image={story.thumbnail} width={400} height={400} />
<StorySummary summary={data.summary} />
<StoryLikeButton story={data} />
<StoryCommentsSection story={data} />
</Card>
);
}
次に、StoryLikeButton.tsx
を見てみましょう。現在、これは何もしないボタンと、「いいね」カウントです。
そのフラグメントを見ると、「いいね」カウントにはlikeCount
フィールドが、いいねボタンが強調表示されているかどうかを判断するにはdoesViewerLike
フィールドがフェッチされていることがわかります(ビューアーがストーリーに「いいね」をしている場合、つまりdoesViewerLike
がtrueの場合、強調表示されます)。
const StoryLikeButtonFragment = graphql`
fragment StoryLikeButtonFragment on Story {
id
likeCount
doesViewerLike
}
`;
「いいね」ボタンを押すと、次のようになります。
- ストーリーがサーバー上で「いいね」されます
- 「
likeCount
」フィールドと「doesViewerLike
」フィールドのローカルクライアントコピーが更新されます。
そのためには、GraphQLミューテーションを記述する必要があります。しかし、その前に...
ミューテーションの構造
GraphQLミューテーションの構文は、次のことを理解していないと混乱します。
GraphQLには、クエリとミューテーションの2種類の要求タイプがあり、どちらもまったく同じように機能します。これは、HTTPにGETとPOSTがあるのと似ています。技術的には、POSTリクエストは効果をもたらすことを意図しているのに対し、GETリクエストは効果をもたらさないことだけが異なります。同様に、ミューテーションはクエリとまったく同じですが、ミューテーションは何かが起こることが想定されている点が異なります。これは、次のことを意味します。
- ミューテーションはクライアントコードの一部です
- ミューテーションは、クライアントがサーバーにデータを渡すことができる_変数_を宣言します
- サーバーは、_個々のフィールド_を実装します。特定のミューテーションは、これらのフィールドを構成し、その変数をフィールド引数として渡します。
- 各フィールドは、特定のタイプのデータ(スカラーまたは他のグラフノードへのエッジ)を生成し、そのフィールドが選択されている場合にクライアントに返されます。エッジの場合、リンクされているノードからさらにフィールドが選択されます。
唯一の違いは、ミューテーションでは、フィールドを選択すると何かが起こり、データが返されることです。
いいね!ミューテーション
それを念頭に置いて、これが私たちのミューテーションのようになるでしょう - これをファイルに追加してください
const StoryLikeButtonLikeMutation = graphql`
mutation StoryLikeButtonLikeMutation(
$id: ID!,
$doesLike: Boolean!,
) {
likeStory(
id: $id,
doesLike: $doesLike
) {
story {
id
likeCount
doesViewerLike
}
}
}
`;
これはたくさんあります。分解してみましょう。
- ミューテーションの名前は
StoryLikeButton
+Like
+Mutation
です。これは、モジュール名で始まり、GraphQL操作で終わる必要があるためです。 - ミューテーションは変数を宣言します。これは、ミューテーションがディスパッチされたときにクライアントからサーバーに渡されます。各変数には名前(
$id
、$doesLike
)とタイプ(ID!
、Boolean!
)があります。タイプの後の!
は、オプションではなく必須であることを示します。 - ミューテーションは、GraphQLスキーマで定義されたミューテーションフィールドを選択します。サーバーが定義する各ミューテーションフィールドは、ストーリーに「いいね」をするなど、クライアントがサーバーに要求できる何らかのアクションに対応します。
- ミューテーションフィールドは引数を取ります(他のフィールドと同様に)。ここでは、宣言したミューテーション変数を引数値として渡します。たとえば、
doesLike
フィールド引数は$doesLike
ミューテーション変数に設定されます。
- ミューテーションフィールドは引数を取ります(他のフィールドと同様に)。ここでは、宣言したミューテーション変数を引数値として渡します。たとえば、
likeStory
フィールドは、ミューテーションレスポンスを表すノードへのエッジを返します。更新データを受信するために、さまざまなフィールドを選択できます。ミューテーションレスポンスで使用可能なフィールドは、GraphQLスキーマで指定されています。- いいね!をしたストーリーへのエッジである
story
フィールドを選択します。 - そのストーリー内から特定のフィールドを選択して、更新データを取得します。これらは、クエリがストーリーについて選択できるフィールドと同じです。実際、フラグメントで選択したフィールドと同じです。
- いいね!をしたストーリーへのエッジである
このミューテーションをサーバーに送信すると、クエリと同様に、送信したミューテーションの形状に一致するレスポンスが返されます。たとえば、サーバーはこれを私たちに送り返すかもしれません
{
"likeStory": {
"story": {
"id": "34a8c",
"likeCount": 47,
"doesViewerLike": true
}
}
}
そして私たちの仕事は、この更新された情報を組み込むためにローカルデータストアを更新することです。Relayは単純なケースではこれを処理しますが、より複雑なケースでは、ストアをインテリジェントに更新するためにカスタムコードが必要になります。
しかし、先走りしています。ボタンにミューテーションをトリガーさせましょう。これが現在のコンポーネントの外観です。onLikeButtonClicked
イベントをStoryLikeButtonLikeMutation
を実行するように接続する必要があります。
function StoryLikeButton({story}) {
const data = useFragment(StoryLikeButtonFragment, story);
function onLikeButtonClicked() {
// To be filled in
}
return (
<>
<LikeCount count={data.likeCount} />
<LikeButton value={data.doesViewerLike} onChange={onLikeButtonClicked} />
</>
)
}
そのためには、useMutation
を呼び出します
import {useMutation, useFragment} from 'react-relay';
function StoryLikeButton({story}) {
const data = useFragment(StoryLikeButtonFragment, story);
const [commitMutation, isMutationInFlight] = useMutation(StoryLikeButtonLikeMutation);
function onLikeButtonClicked() {
commitMutation({
variables: {
id: data.id,
doesLike: !data.doesViewerLike,
},
})
}
return (
<>
<LikeCount count={data.likeCount} />
<LikeButton value={data.doesViewerLike} onChange={onLikeButtonClicked} />
</>
)
}
useMutation
フックは、サーバーに何かを実行するように指示するために呼び出すことができる関数commitMutation
を返します。
variables
というオプションを渡します。このオプションでは、ミューテーションで定義された変数、つまり id
と doesViewerLike
に値を指定します。 これにより、サーバーはどのストーリーについて話しているのか、そして私たちが「いいね」をしているのか「いいね」を取り消しているのかを認識します。 ストーリーの id
はフラグメントから読み取り、いいねをするか取り消すかは、レンダリングされた現在の値を切り替えることで決定されます。
このフックは、ミューテーションが実行中かどうかを示すブール値のフラグも返します。 これを使用して、ミューテーションの実行中にボタンを無効にすることで、ユーザーエクスペリエンスを向上させることができます。
<LikeButton
value={data.doesViewerLike}
onChange={onLikeButtonClicked}
disabled={isMutationInFlight}
/>
これで、ストーリーに「いいね」できるようになりました。
Relay がミューテーションレスポンスを自動的に処理する方法
しかし、Relay はどのようにしてクリックされたストーリーを更新する必要があることを知ったのでしょうか? サーバーはこの形式のレスポンスを返しました。
{
"likeStory": {
"story": {
"id": "34a8c",
"likeCount": 47,
"doesViewerLike": true
}
}
}
レスポンスに id
フィールドを持つオブジェクトが含まれている場合、Relay はストアに、そのレコードの id
フィールドに一致する ID を持つレコードが既に存在するかどうかを確認します。 一致するものがある場合、Relay はレスポンスの他のフィールドを既存のレコードにマージします。 つまり、この単純なケースでは、ストアを更新するためのコードを記述する必要はありません。
ミューテーションレスポンスでのフラグメントの使用
ミューテーションはクエリと同様です。 ミューテーションレスポンスに常にレンダリングに必要なデータが含まれるようにするために、手動で更新する必要がある別のフィールドセットを持つ代わりに、フラグメントをミューテーションレスポンスに展開するだけで済みます。
const StoryLikeButtonLikeMutation = graphql`
mutation StoryLikeButtonLikeMutation(
$id: ID,
$doesLike: Boolean,
) {
likeStory(id: $id, doesLike: $doesLike) {
story {
...StoryLikeButtonFragment
}
}
}
`;
これで、データ要件を追加または削除すると、必要なすべてのデータ(それ以上は含まれません)がミューテーションレスポンスに含まれるようになります。 これは通常、ミューテーションレスポンスを記述する賢明な方法です。 ミューテーションをトリガーするコンポーネントだけでなく、どのコンポーネントからでもフラグメントを展開できます。 これにより、UI 全体を最新の状態に保つことができます。
楽観的アップデーターによる UX の改善
ミューテーションの実行には時間がかかりますが、ユーザーにアクションを実行したというフィードバックを与えるために、UI を何らかの方法で即座に更新したいと考えています。 現在の例では、「いいね」ボタンはミューテーションの実行中は無効になり、ミューテーションが完了した後、Relay が更新されたデータをストアにマージして影響を受けるコンポーネントを再レンダリングすると、UI は新しい状態に更新されます。
多くの場合、最良のフィードバックは、操作が既に完了したかのように見せかけることです。たとえば、「いいね」ボタンを押すと、そのボタンは、既に「いいね」したものが表示されるたびに表示されるのと同じ強調表示された状態にすぐに変わります。 コメントの投稿を例にとってみましょう。投稿されたコメントをすぐに表示したいと考えています。 これは、ミューテーションが通常は高速で信頼性が高いため、ユーザーに個別の読み込み状態を表示する必要がないためです。 ただし、ミューテーションが失敗する場合があります。 その場合は、行った変更をロールバックし、ミューテーションを試みる前の状態に戻します。投稿済みとして表示されたコメントは消え、コメントのテキストは、再度投稿しようとした場合にデータが失われないように、作成したコンポーザー内に再び表示されます。
これらのいわゆる*楽観的更新*の管理は手動で行うのは複雑ですが、Relay には更新を適用およびロールバックするための堅牢なシステムがあります。 ユーザーが複数のボタンを連続してクリックした場合など、複数のミューテーションを同時に実行することもでき、Relay は障害が発生した場合にどの変更をロールバックする必要があるかを追跡します。
ミューテーションは 3 つのフェーズで進行します。
- まず、*楽観的更新*があります。ここでは、ローカルデータストアを、ユーザーにすぐに表示したいと予想される状態に更新します。
- 次に、実際にサーバー上でミューテーションを実行します。 成功した場合、サーバーは更新された情報を返します。この情報は 3 番目のステップで使用できます。
- ミューテーションが完了したら、楽観的更新をロールバックします。 ミューテーションが失敗した場合、完了です。開始位置に戻ります。 ミューテーションが成功した場合、Relay は単純な変更をストアにマージし、サーバーから受信した実際の新しい情報と、その他に行いたい変更を使用してローカルデータストアを更新する*最終更新*を適用します。
この背景知識を踏まえて、「いいね」ボタンの楽観的アップデーターを作成して、クリックしたときにすぐに新しい状態に更新されるようにしましょう。
ステップ 1 — optimisticUpdater オプションを commitMutation に追加する
StoryLikeButton
に移動し、commitMutation
の呼び出しに新しいオプションを追加します。
function StoryLikeButton({story}) {
...
function onLikeButtonClicked(newDoesLike) {
commitMutation({
variables: {
id: data.id,
doesLike: newDoesLike,
},
optimisticUpdater: store => {
// TODO fill in optimistic updater
},
})
}
...
}
このコールバックは、Relay のローカルデータストアを表す store
引数を受け取ります。 ローカルデータの読み取りと書き込みのためのさまざまなメソッドがあります。 楽観的アップデーターで行うすべての書き込みは、ミューテーションがディスパッチされるとすぐに適用され、完了時にロールバックされます。
ステップ 2 — 更新可能なフラグメントを作成する
*更新可能なフラグメント*と呼ばれる特別な種類のフラグメントを記述することで、ローカルストアのデータを読み書きできます。 通常のフラグメントとは異なり、クエリに展開されてサーバーに送信されることはありません。 代わりに、既に知っている GraphQL 構文を使用してローカルストアからデータを読み取ることができます。 このフラグメント定義を追加してください。
function StoryLikeButton({story}) {
...
optimisticUpdater: store => {
const fragment = graphql`
fragment StoryLikeButton_updatable on Story
@updatable
{
likeCount
doesViewerLike
}
`;
},
...
}
他のフラグメントとまったく同じですが、@updatable ディレクティブでアノテーションが付けられています。
通常のフラグメントとは異なり、更新可能なフラグメントはクエリに展開されず、サーバーからフェッチされるデータを選択しません。 代わりに、Relay ローカルデータストアに既に存在するデータを選択して、データを更新できるようにします。
ステップ 3 — readUpdatableFragment を呼び出す
この フラグメントと、プロップとして受け取った 元のフラグメント参照(どのストーリーに「いいね」をしているかを示します)を store.readUpdatableFragment
に渡します。 updatableData
と呼ばれる特別なオブジェクトが返されます。
function StoryLikeButton({story}) {
...
optimisticUpdater: store => {
const fragment = graphql`
fragment StoryLikeButton_updatable on Story @updatable {
likeCount
doesViewerLike
}
`;
const {
updatableData
} = store.readUpdatableFragment(
fragment,
story
);
},
...
}
ステップ 4 — 更新可能なデータを変更する
これで、updatableData
は、ローカルストアに存在する既存のストーリーを表すオブジェクトになります。 フラグメントにリストされているフィールドを読み書きできます。
function StoryLikeButton({story}) {
...
optimisticUpdater: store => {
const fragment = graphql`
fragment StoryLikeButton_updatable on Story @updatable {
likeCount
doesViewerLike
}
`;
const {updatableData} = store.readUpdatableFragment(fragment, story);
const alreadyLikes = updatableData.doesViewerLike;
updatableData.doesViewerLike = !alreadyLikes;
updatableData.likeCount += (alreadyLikes ? -1 : 1);
},
...
}
この例では、doesViewerLike
を切り替え(既に「いいね」しているストーリーのボタンをクリックすると「いいね」が取り消されます)、「いいね」数をそれに応じて増減します。
Relay は updatableData
に対して行った変更を記録し、ミューテーションが完了したらロールバックします。
これで、「いいね」ボタンをクリックすると、UI がすぐに更新されるはずです。
コメントの追加 — コネクションでのミューテーション
Relay が完全に自動的に実行できる唯一のことは、既に見てきたとおりです。ミューテーションレスポンスのノードを、ストア内の同じ ID を共有する既存のノードとマージすることです。 その他のことについては、Relay にさらに情報を提供する必要があります。
コネクションのケースを見てみましょう。 ストーリーに新しいコメントを投稿する機能を実装します。
サーバーのミューテーションレスポンスには、新しく作成されたコメントのみが含まれています。 Relay に、そのストーリーをストーリーとそのコメントの間のコネクションに挿入する方法を指示する必要があります。
StoryCommentsSection
に戻り、新しいコメントを投稿するためのコンポーネントを追加し、そのフラグメントをフラグメントに展開することを忘れないでください。
import StoryCommentsComposer from './StoryCommentsComposer';
const StoryCommentsSectionFragment = graphql`
fragment StoryCommentsSectionFragment on Story
...
{
comments(after: $cursor, first: $count)
@connection(key: "StoryCommentsSectionFragment_comments")
{
...
}
...StoryCommentsComposerFragment
}
`
function StoryCommentsSection({story}) {
...
return (
<>
<StoryCommentsComposer story={data} />
...
</>
);
}
これで、コメントセクションの上部にコンポーザーが表示されるはずです。
次に、StoryCommentsComposer.tsx
の内部を見てみましょう。
function StoryCommentsComposer({story}) {
const data = useFragment(StoryCommentsComposerFragment, story);
const [text, setText] = useState('');
function onPost() {
// TODO post the comment here
}
return (
<div className="commentsComposer">
<TextComposer text={text} onChange={setText} onReturn={onPost} />
<PostButton onClick={onPost} />
</div>
);
}
ステップ 1 — コメント投稿ミューテーションを定義する
前と同様に、ミューテーションを定義する必要があります。 ストーリー ID と追加するコメントのテキストをサーバーに送信します。
const StoryCommentsComposerPostMutation = graphql`
mutation StoryCommentsComposerPostMutation(
$id: ID!,
$text: String!,
) {
postStoryComment(id: $id, text: $text) {
commentEdge {
node {
id
text
}
}
}
}
`;
ここでは、スキーマにより、ミューテーションレスポンスの一部として、新しく作成されたコメントへの新しく作成されたエッジを選択できます。 それを選択し、このエッジをコネクションに挿入することでローカルストアを更新するために使用します。
ステップ 2 — commitMutation を呼び出して投稿する
次に、useMutation
フックを使用して commitMutation
コールバックにアクセスし、onPost
で呼び出します。
function StoryCommentsComposer({story}) {
const data = useFragment(StoryCommentsComposerFragment, story);
const [text, setText] = useState('');
const [commitMutation, isMutationInFlight] = useMutation(StoryCommentsComposerPostMutation);
function onPost() {
setText(''); // Reset the UI
commitMutation({
variables: {
id: data.id,
text,
},
})
}
...
}
ステップ 3 — 宣言型コネクションハンドラーを追加する
この時点で、ネットワークログから、「投稿」をクリックするとサーバーにミューテーションリクエストが送信されることがわかります。ページを更新するとコメントが投稿されたことがわかります。 ただし、UI では何も起こりません。 Relay に、新しく作成されたコメントを、ストーリーからそのコメントへのコネクションに追加するように指示する必要があります。
上記のミューテーションレスポンスで`commentEdge`を選択していることに気付くでしょう。これは新しく作成されたコメントへのエッジです。Relayにそのエッジを追加する接続を伝える必要があります。Relayは、ミューテーションレスポンスのエッジに配置する`@appendEdge`、`@prependEdge`、`@deleteEdge`というディレクティブを提供します。ミューテーションを実行するときに、変更する接続のIDを渡します。Relayは、指定したとおりに、それらの接続からエッジを追加、先頭に追加、または削除します。
新しく作成されたコメントをリストの先頭に表示したいので、`@prependEdge`を使用します。ミューテーション定義に次の追加を行います。
mutation StoryCommentsComposerPostMutation(
$id: ID!,
$text: String!,
$connections: [ID!]!,
) {
postStoryComment(id: $id, text: $text) {
commentEdge
@prependEdge(connections: $connections)
{
node {
id
text
}
}
}
}
ミューテーションに`connections`という変数を追加しました。これを使用して、更新する接続を渡します。
`$connections`変数は、Relayのクライアント側で処理される`@prependEdge`ディレクティブの引数としてのみ使用されます。`$connections`はどの*フィールド*にも引数として渡されないため、サーバーには送信されません。
ステップ4:接続IDをミューテーション変数として渡す
新しいエッジを追加する接続を識別する必要があります。接続は、次の2つの情報で識別されます。
- どのノードから分岐しているか - この場合は、コメントを投稿するストーリーです。
- `@connection`ディレクティブで提供される*キー*。同じノードから複数の接続がある場合に、接続を区別できます。
Relayが提供する特別なAPIを使用して、この情報をミューテーション変数に渡します。
import {useFragment, useMutation, ConnectionHandler} from 'react-relay';
...
export default function StoryCommentsComposer({story}: Props) {
...
function onPost() {
setText('');
const connectionID = ConnectionHandler.getConnectionID(
data.id,
'StoryCommentsSectionFragment_comments',
);
commitMutation({
variables: {
id: data.id,
text,
connections: [connectionID],
},
})
}
...
}
`getConnectionID`に渡す文字列`"StoryCommentsSectionFragment_comments"`は、`StoryCommentSection`で接続を取得するときに使用した識別子です。念のため、その様子を以下に示します。
const StoryCommentsSectionFragment = graphql`
fragment StoryCommentsSectionFragment on Story
...
{
comments(after: $cursor, first: $count)
@connection(key: "StoryCommentsSectionFragment_comments")
{
...
}
`;
一方、引数`data.id`は、接続元の特定のストーリーのIDです。
この変更により、ミューテーションが完了すると、コメントがコメントリストに表示されるはずです。
まとめ
ミューテーションを使用すると、サーバーに変更を要求できます。
- クエリと同様に、ミューテーションはフィールドで構成され、変数を受け入れ、それらの変数をフィールドに引数として渡します。
- ミューテーションによって選択されたフィールドは、ストアを更新するために使用できる*ミューテーションレスポンス*を構成します。
- Relayは、レスポンス内のノードを、一致するIDを持つストア内のノードに自動的にマージします。
- `@appendEdge`、`@prependEdge`、`@deleteEdge`ディレクティブを使用すると、ミューテーションレスポンスからストア内の接続にアイテムを挿入および削除できます。
- アップデーターを使用すると、ストアを手動で操作できます。
- オプティミスティックアップデーターは、ミューテーションが開始される前に実行され、ミューテーションが完了するとロールバックされます。