つながりを深める「ダイレクトメッセージ機能」をリリースしました | Wantedly, Inc.
ダイレクトメッセージ機能とは?ダイレクトメッセージ機能の使い方自分から相手にメッセージを送る相手からのメッセージを確認する場合ご注意事項ダイレクトメッセージ機能とは?「ダイレクトメッセージ機能...
https://www.wantedly.com/companies/wantedly/post_articles/1038949
こんにちは、ウォンテッドリーの VIsit Social Squad というチームでソフトウェアエンジニアをしてる川辺(@shintaro_dev)です。先月の 2026/01/26 に Wantedly Visit にダイレクトメッセージ機能がリリースされました。
このダイレクトメッセージ機能におけるチャット一覧 UI に私たちが「双方向ページネーション」と呼んでいる複雑な要件が存在します。今回はこの要件を満たすためのアプローチを、フロントエンド実装の状態管理にフォーカスしてお話ししたいと思います。
なお、今回使用しているライブラリのバージョンは次のようになっています。
next: 14.2.32
react: 18.3.1
@apollo/client: 3.6.6
はじめに: 双方向ページネーションの難しさ
要件に潜む厄介な状態管理とビジネスロジックの課題
状態管理の課題
ビジネスロジックの課題
Apollo Client の Field Policy をビジネスロジック層として扱い、コンポーネントをシンプルにする
(解説)keyArgs: 異なるページネーションリクエストごとにキャッシュエントリを共有する
(解説)merge: 双方向ページネーションのレスポンスデータ結合処理
ステートと副作用を持たなくなった UI コンポーネント
改善前の状態
まとめ:複雑な状態とロジックほど UI と切り離す
今回のダイレクトメッセージ機能の開発では、上下両方向にリストが伸長する「双方向ページネーション」の実装が必要でした。ユーザーが過去の会話を確認するために上方向へスクロールした際は、過去のデータを取得してリストの後方に結合しなければなりません。同時に、新しいメッセージをリアルタイムに受け取るために、ポーリングによって未来方向のデータを取得し、リストの先頭に追加する挙動も求められます。※
※ 余談ですが「概念的にはどちらの方向を前方(Forward)とするかか?」は設計において議論を重ねたポイントでした。今回は時系列として逆であっても「ユーザーがコンテンツを進んでいく方向が前方である」という解釈を行い、過去に遡る方向を前方へのページネーションと定義しています。UI が扱うデータの配列順序もこの概念に従って実際の UI 時系列の並びとは逆で返すようにしました。
また、バックエンドの設計については今回説明を省きますがインタフェースは、Relay-style Connection 仕様をベースに before(前方取得用のカーソル)と after(後方取得用のカーソル)の引数を使い分けることで同じクエリで両方向のフェッチを解釈するアプローチを採用しました。
今回のこのような要件を満たすため、どのようにして状態とロジックを設計するかは重要な実装テーマの一つでした。双方向ページネーションの実装要件は特徴として
といった側面を持ちます。誤った設計アプローチを採用するとUIコンポーネントにこの複雑な状態管理とロジックの責務が移ってしまい、以下のような問題につながることが予測されました。
このテーマについて、まずは状態管理の側面から考えます。 React における”状態”は整理すると、大きく次の 3 つに分類できると考えています。
(※イメージが付きづらい方は、次の記事がおすすめです。=> 「3種類」で管理するReactのState戦略)
メッセージリストの状態は性質として API Cache であるべきです。 せっかく Apollo Client という優れたキャッシュ機構の備わったライブラリを使っているのにレスポンスデータを useState に管理したりすると、状態が二重管理になってしまう上、状態がそもそも UI に寄ってしまい冒頭で言及した問題につながりやすくなってしまいます。
今回用いている GraphQL API は、その都度リクエストされた範囲のデータのみを返します。しかし、UI が最終的に必要としているのは「これまで読み込んだすべてのメッセージが正しく並んだ一つの状態」です。メッセージ一覧レスポンスの断片を繋ぎ合わせ、最終的な一つの状態を作るビジネスロジックをどこに配置するかが設計の分岐点となります。ここも React 側で解決しようとして useEffect などを用いたコンポーネント側でのビジネスロジック処理を書いてしまうと冒頭の問題を引き起こします。
そこで、状態管理とデータの統合ロジックを Apollo Client の Field Policy を用いた API Cache で一元管理する設計を採用しました。Field Policy を活用すると、サーバーから返却された断片的なデータをどのように既存のキャッシュと結合するかを、キャッシュ層のルールとして定義できます。これにより、UI コンポーネントは状態を持つ責務、さらにデータのマージ処理を持つ責務から外れ、単にクエリの結果を観測して表示するだけのシンプルな構造になります。
Field Policy を用いて書かれた API Cache のマージ処理は次のようになっています。(Apollo Client インスタンスを初期化する際の InMemoryCache 設定に記述します。)
const client = new ApolloClient({
// ...
cache: new InMemoryCache({
// ...
Conversation: {
keyFields: ["id"],
fields: {
conversationItems: {
// keyArgs を false にすることで、異なる引数のクエリでも同じキャッシュエントリを共有する
// これによってページネーションごとのデータ更新を同じキャッシュに統合する
keyArgs: false,
// 初回 fetch およびページネーション発生後の response を merge する
// 注意すべきなのは、この時点では UI に表示する順序と逆になっていること。
merge(existing, incoming, { args, readField }) {
const existingEdges = existing?.edges || [];
const incomingEdges = incoming?.edges || [];
// ConversationItem の構造: edge.node.item に ConversationMessage がある
// id は edge.node.item.id にあるため、readField を2回使用する
const getItemId = (edge: any) => {
const node = edge?.node;
const item = readField("item", node);
return readField("id", item as Reference);
};
const existingIds = new Set(existingEdges.map(getItemId));
const uniqueIncomingEdges = incomingEdges.filter((edge: any) => !existingIds.has(getItemId(edge)));
// 既存のデータが存在しない/既存のデータが0件の場合は有効なデータがない状態として取得されたデータを返す
if (!existing || existingEdges.length === 0) {
return incoming;
}
// 前方ページネーション: after が指定されている場合
if (args?.after) {
return {
...existing,
edges: [...existingEdges, ...uniqueIncomingEdges],
pageInfo: {
...existing.pageInfo,
// endCursor のみ更新(startCursor は後方ページネーション用に保持)
endCursor: incoming.pageInfo.endCursor,
hasNextPage: incoming.pageInfo.hasNextPage,
},
};
}
// 後方ページネーション: before にカーソル値が指定されている場合
if (args?.before && incomingEdges.length > 0) {
return {
...existing,
edges: [...uniqueIncomingEdges, ...existingEdges],
pageInfo: {
...existing.pageInfo,
// startCursor のみ更新(endCursor は前方ページネーション用に保持)
startCursor: incoming.pageInfo.startCursor,
},
};
}
// ポーリングで新着がない場合など、既存データをそのまま返す
return existing;
},
},
},
},
})
});通常、Apollo Client はクエリ引数が異なると別々のキャッシュとして保存します。しかし、双方向ページネーションでは before で取ろうが after で取ろうが、同じメッセージリストのデータとして集約されないと状態管理として成り立ちません。 keyArgs option を false に設定することで、すべてのレスポンスを同一のキャッシュエントリとして管理できます。
同じキャッシュエントリとして保持できるようになっただけでは、実際に送られてくるデータをメッセージリストのどちらの方向に統合したら良いか、Apollo Client は知りません。そこでレスポンスデータを最終的なキャッシュとして結合する処理は merge 関数で行います。
このようにデータの結合ルールを API Cache レイヤーで定義することで、UI はこれらの状態管理とロジックの詳細を一切知る必要がなくなりました。
Cache に状態管理とロジックを寄せたことで、fetchMore や polling が実行されても、その結果を setState する必要はありません。 UI はただ useQuery を通じて「キャッシュという単一の情報源」を取得してレンダリングするだけです。キャッシュが merge 関数によって更新されれば、UI はリアクティブに再レンダリングされます。さきほどの Cache の処理のおかげで コンポーネントの処理は次のようになっています。
import { useCallback } from "react";
import { NetworkStatus, useQuery } from "@apollo/client";
const ConversationQuery = gql(/* GraphQL */ `
query GetConversation(
$conversationId: String!
$targetUserId: String!
$first: Int
$after: String
$last: Int
$before: String
) {
conversation(conversationId: $conversationId, targetUserId: $targetUserId) {
id
...GetParticipantUsers
conversationItems(first: $first, after: $after, last: $last, before: $before) {
...GetConversationItemEdges
}
}
}
`);
const ITEMS_PER_PAGE = 100;
const POLLING_INTERVAL = 10000;
type Props = {
conversationId?: string;
};
export const MessageContainer: React.FC<Props> = ({ conversationId }) => {
// 1. 初回データ取得 + fetchMore のベースとなるクエリ
const {
data,
networkStatus,
fetchMore,
} = useQuery(ConversationQuery, {
variables: {
conversationId: conversationId || "",
first: ITEMS_PER_PAGE,
after: null,
last: null,
before: null,
},
skip: !conversationId,
notifyOnNetworkStatusChange: true,
});
// 次回取得時の variables の情報となる値
const pageInfo = data?.conversation?.conversationItems?.pageInfo;
// 2. Polling クエリ(新着メッセージ取得用)
// Apollo のキャッシュ正規化により、結果は自動的に上記の useQuery 結果の data にマージされる
useQuery(ConversationQuery, {
variables: pageInfo?.startCursor
? {
// 後方ページネーション(新しいメッセージを取得)
conversationId: conversationId || "",
first: null,
after: null,
last: ITEMS_PER_PAGE,
before: pageInfo.startCursor,
}
: {
// 初期状態(メッセージがまだない場合)
conversationId: conversationId || "",
first: ITEMS_PER_PAGE,
after: null,
last: null,
before: null,
},
pollInterval: POLLING_INTERVAL,
skip: !conversationId || networkStatus === NetworkStatus.loading || !data?.conversation,
});
// 3. 前方ページネーション発火用 callback(過去のメッセージを読み込む)
const fetchMoreForwardData = useCallback(async () => {
await fetchMore({
variables: {
conversationId: conversationId || "",
first: ITEMS_PER_PAGE,
after: pageInfo?.endCursor ?? null,
},
});
}, [fetchMore, conversationId, pageInfo?.endCursor]);
return (
// UI
// ...
)
};
UI に与えられた責務はあくまで 「API の呼び出し方」にとどまっており、useState や useEffect といった API を使用していないのがわかるかと思います。UI がステートや副作用を持たないため、不用意な再レンダリングの抑制やクリーンなコンポーネントに保つことが実現できました。
一番最初の実装ではベースを AI に実装をさせていて、API Cache を考慮せずコンポーネント側で状態とロジックを制御する以下のような実装になっていました。useState と useEffect の処理が複雑でレンダリングサイクルごとにリストがどういう状況なのか追うのが難しくなっています。このレベルまでコンポーネントが複雑になった際は、ただ処理をカスタムフック等に切り出すだけでなく、根本的な部分から状態とロジック配置の設計を検討し直しましょう。
import { useState, useEffect, useCallback } from "react";
import { NetworkStatus, useQuery } from "@apollo/client";
const ConversationQuery = gql(/* GraphQL */ `
query GetConversation(
$conversationId: String!
$targetUserId: String!
$first: Int
$after: String
$last: Int
$before: String
) {
conversation(conversationId: $conversationId, targetUserId: $targetUserId) {
id
...GetParticipantUsers
conversationItems(first: $first, after: $after, last: $last, before: $before) {
...GetConversationItemEdges
}
}
}
`);
const ITEMS_PER_PAGE = 100;
const POLLING_INTERVAL = 10000;
type Props = {
conversationId?: string;
};
export const MessageContainer: React.FC<Props> = ({ conversationId }) => {
const { t } = useI18n(book);
const client = useGraphqlGatewayApolloClient();
/*
* ⚠️ 双方向ページネーションの結果のデータをマージする処理を useEffect で書いてしまっている
*/
const [localMessages, setLocalMessages] = useState<MessageEdges>([]);
const [pageInfo, setPageInfo] = useState<PageInfo>({
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
endCursor: null,
});
const {
data: initialOrForwardData,
networkStatus: initialOrForwardNetworkStatus,
fetchMore,
} = useQuery<GetConversationQuery, GetConversationQueryVariables>(ConversationQuery, {
client,
variables: {
conversationId: conversationId || "",
targetUserId: "",
first: 10,
after: null,
last: null,
before: null,
},
skip: !conversationId,
});
const { data: backwardData } = useQuery<GetConversationQuery, GetConversationQueryVariables>(ConversationQuery, {
client,
variables: {
conversationId: conversationId || "",
targetUserId: "",
first: null,
after: null,
last: 10,
before: pageInfo.startCursor,
},
pollInterval: 10000,
skip: !conversationId,
});
if (backwardData) {
setLocalMessages([]);
}
/*
* ⚠️ 双方向ページネーションの結果のデータをマージする処理を useEffect で書いてしまっている
*/
useEffect(() => {
if (!initialOrForwardData) return;
if (!initialOrForwardData.conversation) return;
if (!initialOrForwardData.conversation.conversationMessages) return;
if (!initialOrForwardData.conversation.conversationMessages.edges) return;
const messages = initialOrForwardData.conversation.conversationMessages.edges as MessageEdges;
const pageInfo = initialOrForwardData.conversation.conversationMessages.pageInfo;
if (messages.length !== 0) {
const reversedMessages = [...messages].reverse();
setLocalMessages((prev) => [...reversedMessages, ...prev]);
}
setPageInfo((prev) => ({ ...prev, endCursor: pageInfo.endCursor }));
}, [initialOrForwardData]);
useEffect(() => {
if (!backwardData) return;
if (!backwardData.conversation) return;
if (!backwardData.conversation.conversationMessages) return;
if (!backwardData.conversation.conversationMessages.edges) return;
const messages = backwardData.conversation.conversationMessages.edges as MessageEdges;
const pageInfo = backwardData.conversation.conversationMessages.pageInfo;
if (messages.length !== 0) {
const reversedMessages = [...messages].reverse();
setLocalMessages((prev) => [...reversedMessages, ...prev]);
}
setPageInfo((prev) => ({ ...prev, startCursor: pageInfo.startCursor }));
}, [backwardData]);
return (
// ...
)
};今回の設計のポイントは、UI層をデータの表示に専念させ、状態管理とビジネスロジックの責務を Apollo Client の Cache 層へ集約した点にあります。
双方向ページネーションのような複雑なデータフローにおいても、ロジックを Cache 層に閉じることで、データ不整合やパフォーマンス低下のリスクを最小化できました。技術的な複雑性をいかに適切なレイヤーへ配置できるかをきちんと検討することが、結果としてシンプルで保守性の高いコードベースの実現に繋がっています。