convex-realtime について
React と Convex を使う開発者向けのガイドです。リアルタイム購読、楽観的更新、ページネーションを説明し、無関係な書き込みによる再実行を抑える方法も扱います。
主な機能
- 01useQuery によるリアルタイム購読
- 02楽観的更新とローカルロールバック
- 03更新が続くページネーション
- 04動的な並列クエリ
- 05読み取り範囲を絞った再実行の抑制
- 06プレゼンス実装の指針
活用シーン
- 01React コンポーネントと Convex データの接続
- 02サーバー更新が画面に反映されない問題の調査
- 03ライブ更新される追加読み込みリストの構築
- 04オンライン状態や入力中状態の表示
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ waynesutton/ builder-skills --skill convex-realtimeインストール後、Claude Code で/convex-realtimeを入力して読み込まれたか確認します。
同梱ファイルagents/openai.yamlassets/small-logo.svg
詳細
Convex のリアルタイム機能
すべての useQuery はライブサブスクリプションです。ルールはひとつです。クエリが読み取るドキュメントの集合は、小さく、インデックスを使うようにしてください。この読み取り対象によって、クエリが再実行されるタイミングが決まります。
どのようなときに使うか
- React コンポーネントを初めて Convex に接続するとき
- サーバー上でデータが変わっても、ページが更新されないとき
- 無関係な書き込みのたびに、リストやダッシュボードが再レンダリングされるとき
- 新しいデータが届くと、「さらに読み込む」リストから行が消えたり、重複したりするとき
- オンライン中のユーザーや、入力中のユーザーを表示するとき
サブスクリプションの仕組み
useQuery はクライアントの WebSocket を介してサブスクリプションを開始します。サーバーはクエリを実行し、読み取り対象(アクセスしたすべてのドキュメントとインデックス範囲)を記録して、結果をプッシュします。読み取り対象と重なる書き込みをミューテーションがコミットすると、サーバーはクエリを再実行し、新しい結果をプッシュします。重ならなければ、再実行されません。結果は関数と引数ごとにキャッシュされるため、2 つのコンポーネントが useQuery(api.tasks.list, { userId }) を呼び出すと、1 つのサブスクリプションを共有します。アクティブなサブスクリプションはすべて、同じデータベースのタイムスタンプで更新されるため、UI にミューテーションが一部だけ適用された状態が表示されることはありません。この仕組みを成立させるには、クエリが決定的である必要があります。そのため、クエリ内では Date.now()、Math.random()、fetch は使えません。
useQuery
undefined は読み込み中を意味します。フックを条件分岐で囲む代わりに、"skip" を渡してください。
import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
import { Id } from "../convex/_generated/dataModel";
function TaskList({ userId }: { userId: Id<"users"> | null }) {
const tasks = useQuery(api.tasks.list, userId ? { userId } : "skip");
if (userId === null) return <p>Select a user</p>;
if (tasks === undefined) return <p>Loading</p>;
return (
<ul>
{tasks.map((task) => (
<li key={task._id}>{task.title}</li>
))}
</ul>
);
}
バックエンドのクエリは、検証済みのデータを返し、インデックスを使って読み取ります。
// convex/tasks.ts
import { query } from "./_generated/server";
import { v } from "convex/values";
export const list = query({
args: { userId: v.id("users") },
returns: v.array(
v.object({
_id: v.id("tasks"),
_creationTime: v.number(),
userId: v.id("users"),
title: v.string(),
completed: v.boolean(),
}),
),
handler: async (ctx, args) => {
return await ctx.db
.query("tasks")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.order("desc")
.take(100);
},
});
ミューテーションと楽観的更新
ほとんどの UI では、通常の useMutation 呼び出しで十分です。書き込みがコミットされると、通常は 1 往復以内にサブスクリプションが更新されます。その往復が目に見える場合は、トグル、並べ替え、チャットの送信などに楽観的更新を追加してください。
import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
import { Id } from "../convex/_generated/dataModel";
function useToggleTask(userId: Id<"users">) {
return useMutation(api.tasks.toggle).withOptimisticUpdate(
(localStore, args) => {
const current = localStore.getQuery(api.tasks.list, { userId });
if (current === undefined) return;
localStore.setQuery(
api.tasks.list,
{ userId },
current.map((task) =>
task._id === args.taskId
? { ...task, completed: !task.completed }
: task,
),
);
},
);
}
リストへの挿入も同じ方法で行えます。プレースホルダーの _id と _creationTime: Date.now()(ここではクライアント上で実行されるため使用可能です)を持つ一時的なドキュメントを作成して先頭に追加し、サーバーの結果で置き換えます。ミューテーションが例外をスローした場合、Convex がローカルストアをロールバックします。画面に表示されているクエリだけでなく、ミューテーションの影響を受けるすべてのクエリを更新してください。そうしないと、サーバーから応答が返るまで、ほかのクエリが古い状態のままになります。
ライブ更新を維持するページネーション
// convex/messages.ts
import { query } from "./_generated/server";
import { v } from "convex/values";
import { paginationOptsValidator } from "convex/server";
export const listByChannel = query({
args: { channelId: v.id("channels"), paginationOpts: paginationOptsValidator },
// Return shape is fixed by .paginate(): { page, isDone, continueCursor }
handler: async (ctx, args) => {
return await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.order("desc")
.paginate(args.paginationOpts);
},
});
import { usePaginatedQuery } from "convex/react";
import { api } from "../convex/_generated/api";
import { Id } from "../convex/_generated/dataModel";
function MessageList({ channelId }: { channelId: Id<"channels"> }) {
const { results, status, loadMore } = usePaginatedQuery(
api.messages.listByChannel,
{ channelId },
{ initialNumItems: 30 },
);
return (
<div>
{results.map((m) => (
<p key={m._id}>{m.body}</p>
))}
{status === "CanLoadMore" && (
<button onClick={() => loadMore(30)}>Load more</button>
)}
{status === "LoadingMore" && <p>Loading</p>}
</div>
);
}
読み込んだ各ページはそれぞれ独立したサブスクリプションなので、再取得しなくてもリスト内のどこで挿入や削除が発生したかにかかわらず、その変更が反映されます。status は LoadingFirstPage、CanLoadMore、LoadingMore、Exhausted のいずれかです。無限スクロールでは、status === "CanLoadMore" のときに、監視用要素に設定した IntersectionObserver から loadMore を呼び出してください。カーソルは .paginate() からのみ取得できます。.collect() の結果をスライスして、オフセットベースのページネーションを構築しないでください。
useQueries による並列読み込み
1 つのコンポーネント内で複数の useQuery を呼び出すと、すでに並列で読み込まれ、相互に一貫した状態が保たれます。リスト内の ID ごとにクエリを実行する場合など、クエリの集合が動的なときは useQueries を使ってください。
import { useQueries } from "convex/react";
import { api } from "../convex/_generated/api";
import { Id } from "../convex/_generated/dataModel";
function Avatars({ userIds }: { userIds: Array<Id<"users">> }) {
const users = useQueries(
Object.fromEntries(
userIds.map((userId) => [userId, { query: api.users.get, args: { userId } }]),
),
);
return (
<div>
{userIds.map((id) => {
const user = users[id];
if (user === undefined) return <span key={id}>...</span>;
if (user instanceof Error) return <span key={id}>!</span>;
return <img key={id} src={user.avatarUrl} alt={user.name} />;
})}
</div>
);
}
読み込み中の各値は undefined、失敗時は Error、それ以外の場合はクエリ結果です。
不要な再実行を避ける
不要な再実行とは、コンポーネントが必要としていない書き込みによって、クエリが再実行されることです。まずサーバー側で対処してください。
- 読み取り対象を絞り込みます。等価条件や範囲条件を指定した
withIndexは、一部分だけを読み取ります。.filter()や単独の.query("table")はテーブル全体を読み取るため、テーブルへの書き込みがあるたびにクエリが再実行されます。 - コンポーネントが描画するものだけを返します。5 つのテーブルを結合するクエリは、そのいずれかのテーブルが変更されると再実行されます。
- クエリ内で
Date.now()を使わないでください。時刻を引数として渡し、丸めることで引数を安定させます。useQuery(api.tasks.overdue, { now: Math.floor(Date.now() / 60000) * 60000 })は、レンダリングのたびではなく、1 分に 1 回再実行されます。 .take(n)またはページネーションを使って、結果数に上限を設けます。増え続けるテーブルに対する.collect()は、読み取り対象も増え続けます。- 頻繁に更新されるフィールドは、専用のテーブルに分けます。ユーザードキュメントの
lastSeenタイムスタンプは、ユーザーを読み取るすべてのクエリを再実行させます。独自のクエリを持つpresenceテーブルに格納してください。 - 入力が急速に変化する操作(入力、ドラッグなど)によるミューテーションは、デバウンスして、中間状態でサブスクリプションが過剰に更新されないようにします。
プレゼンス
プレゼンスは、各クライアントからのハートビートのミューテーションと、最近ハートビートがあった行を検索するクエリです。他のデータに影響しないよう、別のテーブルに保持します。独自に実装するのではなく、@convex-dev/presence コンポーネントを使用してください。ハートビート、切断時のクリーンアップ、React フックを処理します。
// convex/convex.config.ts
import { defineApp } from "convex/server";
import presence from "@convex-dev/presence/convex.config";
const app = defineApp();
app.use(presence);
export default app;
npm install @convex-dev/presence でインストールし、サーバーラッパーと usePresence フックについては、パッケージの README に従ってください。独自に実装する必要がある場合は、ハートビートを専用のテーブルに保持し、最後のハートビートが最近のものであれば早期リターンすることでサーバー側で重複を排除し、roomId のインデックスを使って読み取ってください。
よくある間違い
| 間違い | 問題が起きる理由 | 代わりに行うこと |
|---|---|---|
if (userId) useQuery(...) | レンダリングごとにフックの順序が変わります | useQuery(fn, userId ? args : "skip") |
undefined を空として扱う | 読み込み中の状態が「結果なし」として表示されます | 最初に === undefined を確認します |
クエリ内の Date.now() | 非決定的で、キャッシュが機能しなくなります | now を引数として渡し、丸めます |
大きなテーブルに対する .filter() | テーブル全体が読み取りセットに含まれます | インデックスを追加し、withIndex を使います |
フィードに対する .collect() | テーブルに応じて読み取りセットが大きくなります | .take(n) または .paginate() を使います |
| 1 つのクエリだけを楽観的に更新する | サーバーが応答するまで、他のビューが古いままになります | コールバック内で影響を受けるすべてのクエリを更新します |
.collect() をスライスするオフセットページネーション | 行が挿入されるとページがずれます | .paginate() と usePaginatedQuery を使います |
ユーザードキュメントに lastSeen を保持する | ハートビートのたびにすべてのユーザークエリが再実行されます | プレゼンス専用のテーブルまたはコンポーネントを使います |
チェックリスト
- すべての
useQueryで、結果を読み取る前にundefinedを処理している - 条件付きクエリでは、条件付きのフック呼び出しではなく
"skip"を使っている - すべてのバックエンドクエリに
argsとreturnsのバリデーターがある - すべてのクエリが
withIndex経由で読み取っており、大きなテーブルに.filter()を使っていない - いずれのクエリ内にも
Date.now()やMath.random()がない - 増加する可能性のあるリストで
.paginate()とusePaginatedQueryを使っている - 楽観的更新で、ミューテーションによって変更されるすべてのクエリを更新している
- ハートビートなどの更新頻度が高いフィールドを専用のテーブルに保持している
- 頻繁な入力によるミューテーションをクライアント側でデバウンスしている