Convex で Presence(在席)機能を実装:ユーザーステータスのリアルタイム同期

リアルタイムでユーザーの在席状況を表示する機能は、チャットアプリケーションやコラボレーションツールにおいて欠かせない要素です。Convex を使えば、WebSocket ベースのリアルタイム同期とサーバーレスな関数を組み合わせて、シンプルかつスケーラブルな Presence 機能を実装できます。
本記事では、Convex のリアルタイムデータベースと Mutation、Query を活用して、ユーザーの在席状態やタイピング状態をリアルタイムに同期する方法を解説します。初心者の方でも実装できるよう、段階的にコードを紹介していきますね。
背景
Presence 機能とは
Presence(在席)機能とは、アプリケーション上でユーザーが「オンライン」「オフライン」「離席中」といったステータスをリアルタイムに表示する仕組みです。また、チャットアプリでは「○○ さんが入力中...」といったタイピングインジケーターも Presence の一種となります。
Convex の特徴
Convex は、リアルタイムデータベースとサーバーレス関数を統合したバックエンドプラットフォームです。WebSocket による自動同期機能を備えており、データベースの変更が即座にクライアントに反映されます。
従来の実装では、WebSocket サーバーの構築や Redis などのキャッシュ層の管理が必要でしたが、Convex ではこれらのインフラ管理が不要となり、開発者は機能実装に集中できるのです。
下図は、従来の Presence 実装と Convex を使った実装の違いを示したものです。
mermaidflowchart TB
subgraph traditional["従来の構成"]
client1["クライアント"] -->|WebSocket| wsServer["WebSocket<br/>サーバー"]
wsServer -->|状態保存| redis[("Redis")]
wsServer -->|永続化| db1[("PostgreSQL")]
end
subgraph convex_arch["Convex の構成"]
client2["クライアント"] -->|自動同期| convexApi["Convex<br/>リアルタイムAPI"]
convexApi -->|統合DB| convexDb[("Convex DB")]
end
従来の構成では複数のコンポーネントを組み合わせる必要がありましたが、Convex では単一のプラットフォームで完結します。
課題
リアルタイム同期の複雑さ
Presence 機能を実装する際、以下のような課題に直面することが多いです。
WebSocket 接続の管理
ユーザーの接続・切断を適切に追跡し、接続が切れた際に自動的にオフライン状態へ更新する必要があります。接続状態の監視とタイムアウト処理を実装するのは意外と複雑です。
状態の一貫性
複数のユーザーが同時に状態を更新する場合、データの一貫性を保つ必要があります。特に、ユーザーが複数のタブやデバイスから同時にアクセスしている場合の処理は慎重に設計しなければなりません。
スケーラビリティ
ユーザー数が増加した際に、すべてのユーザーの状態を効率的に配信する仕組みが求められます。無駄な通信を減らし、必要な情報だけを適切なタイミングで送信することが重要ですね。
下図は、Presence 機能における主な課題を整理したものです。
mermaidflowchart LR
presence["Presence 機能"] -->|課題1| connection["接続管理<br/>接続・切断の追跡"]
presence -->|課題2| consistency["状態の一貫性<br/>複数デバイス対応"]
presence -->|課題3| scale["スケーラビリティ<br/>効率的な配信"]
connection -->|解決策| convex1["Convex の<br/>自動接続管理"]
consistency -->|解決策| convex2["Convex の<br/>トランザクション"]
scale -->|解決策| convex3["Convex の<br/>リアクティブクエリ"]
これらの課題を個別に解決するのは大変ですが、Convex では統合的なソリューションが提供されています。
解決策
Convex による Presence 機能の実装アプローチ
Convex では、以下の要素を組み合わせて Presence 機能を実装します。
データスキーマの設計
ユーザーの在席状態を保存するテーブルを定義します。ユーザー ID、ステータス、最終更新時刻などのフィールドを含めることで、状態管理を実現できます。
Mutation による状態更新
クライアントからの状態変更リクエストを Mutation 関数で受け取り、データベースを更新します。Convex の Mutation はトランザクション処理されるため、データの一貫性が保証されるのです。
Query によるリアルタイム購読
Query 関数を使ってデータベースの状態を取得し、クライアント側で購読します。データが更新されると自動的に再実行され、最新の状態がクライアントに反映されます。
ハートビート機構
定期的にクライアントからハートビート信号を送信し、接続状態を確認します。一定時間ハートビートが途絶えた場合は、自動的にオフライン状態へ更新する仕組みを実装できますね。
以下の図は、Convex を使った Presence 機能の全体的なデータフローを示したものです。
mermaidsequenceDiagram
participant User as ユーザー
participant Client as クライアント
participant Query as Query 関数
participant Mutation as Mutation 関数
participant DB as Convex DB
User->>Client: アプリを開く
Client->>Mutation: updatePresence("online")
Mutation->>DB: 状態を更新
DB-->>Query: 変更を通知
Query-->>Client: 最新状態を配信
Client->>User: オンライン表示
loop ハートビート
Client->>Mutation: heartbeat()
Mutation->>DB: 最終更新時刻を更新
end
User->>Client: アプリを閉じる
Client->>Mutation: updatePresence("offline")
Mutation->>DB: 状態を更新
DB-->>Query: 変更を通知
Query-->>Client: 最新状態を配信
このシーケンス図から、クライアントが状態を更新すると即座にデータベースへ反映され、すべての購読クライアントへリアルタイムに配信される流れが理解できます。
実装の全体像
実装は以下のステップで進めていきます。
# | ステップ | 内容 |
---|---|---|
1 | プロジェクト初期化 | Convex プロジェクトのセットアップと必要なパッケージのインストール |
2 | スキーマ定義 | Presence データを保存するテーブルスキーマの定義 |
3 | Mutation 実装 | 状態更新とハートビート処理の関数実装 |
4 | Query 実装 | 在席状態を取得するクエリ関数の実装 |
5 | クライアント実装 | React コンポーネントでの Presence 表示と更新処理 |
6 | 自動クリーンアップ | 古い Presence データを削除する定期実行処理 |
それでは、具体的な実装に進みましょう。
具体例
プロジェクトの初期化
まず、Convex プロジェクトをセットアップします。既存の React プロジェクトがある前提で、Convex を追加していきますね。
Convex のインストール
以下のコマンドで Convex をプロジェクトに追加します。
bashyarn add convex
このコマンドにより、Convex のクライアントライブラリとサーバー関数を記述するためのツールがインストールされます。
Convex の初期化
次に、Convex プロジェクトを初期化します。
bashyarn convex dev
初回実行時には Convex のアカウント作成とプロジェクトのセットアップが求められます。このコマンドを実行すると、convex/
ディレクトリが作成され、開発サーバーが起動しますよ。
スキーマの定義
Convex のスキーマファイルを作成し、Presence データの構造を定義します。
スキーマファイルの作成
convex/schema.ts
ファイルを作成し、以下の内容を記述します。
typescriptimport { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';
まず、Convex のスキーマ定義に必要な関数とバリデーター(v
)をインポートします。
Presence テーブルの定義
Presence 情報を保存するテーブルを定義します。
typescriptexport default defineSchema({
presence: defineTable({
// ユーザーID(文字列)
userId: v.string(),
// ステータス: "online" | "away" | "offline"
status: v.string(),
// 最終更新時刻(ミリ秒単位のタイムスタンプ)
lastSeen: v.number(),
// オプション: 現在入力中のルームID
typingInRoom: v.optional(v.string()),
})
// userId でインデックスを作成し、高速検索を可能に
.index('by_user', ['userId'])
// lastSeen でインデックスを作成し、古いデータの削除を効率化
.index('by_last_seen', ['lastSeen']),
});
このスキーマでは、各ユーザーの在席状態を管理するために必要なフィールドを定義しています。インデックスを追加することで、ユーザー ID による検索や古いデータの削除を高速に実行できるのです。
Mutation 関数の実装
クライアントから呼び出される状態更新関数を実装します。
Mutation ファイルの作成
convex/presence.ts
ファイルを作成し、インポート文を記述します。
typescriptimport { mutation, query } from './_generated/server';
import { v } from 'convex/values';
Convex が自動生成する _generated/server
から、mutation
と query
関数をインポートします。
Presence 更新 Mutation
ユーザーの在席状態を更新する Mutation を実装します。
typescript// ユーザーの在席状態を更新する
export const updatePresence = mutation({
// 引数の型定義
args: {
userId: v.string(),
status: v.string(),
typingInRoom: v.optional(v.string()),
},
// ハンドラー関数
handler: async (ctx, args) => {
const { userId, status, typingInRoom } = args;
// 現在時刻を取得(ミリ秒)
const now = Date.now();
// 既存の Presence データを検索
const existing = await ctx.db
.query("presence")
.withIndex("by_user", (q) => q.eq("userId", userId))
.first();
この部分では、引数として受け取った userId
をもとに、既存の Presence データをデータベースから検索しています。
データの挿入または更新
既存データがあれば更新し、なければ新規作成します。
typescript if (existing) {
// 既存データを更新
await ctx.db.patch(existing._id, {
status,
lastSeen: now,
typingInRoom,
});
} else {
// 新規データを挿入
await ctx.db.insert("presence", {
userId,
status,
lastSeen: now,
typingInRoom,
});
}
return { success: true };
},
});
patch
メソッドで既存レコードを更新し、insert
メソッドで新規レコードを追加します。これにより、ユーザーの状態が常に最新の情報に保たれるのです。
ハートビート Mutation
クライアントが定期的に呼び出すハートビート関数を実装します。
typescript// 定期的に呼び出されるハートビート関数
export const heartbeat = mutation({
args: {
userId: v.string(),
},
handler: async (ctx, args) => {
const { userId } = args;
const now = Date.now();
// 該当ユーザーの Presence データを取得
const existing = await ctx.db
.query('presence')
.withIndex('by_user', (q) => q.eq('userId', userId))
.first();
if (existing) {
// 最終更新時刻を更新
await ctx.db.patch(existing._id, {
lastSeen: now,
});
}
},
});
ハートビート関数は、ユーザーがまだアクティブであることを示すために lastSeen
フィールドを更新します。これにより、接続が切れたユーザーを検出できますね。
Query 関数の実装
クライアントが購読する Query 関数を実装します。
全ユーザーの Presence を取得
すべてのユーザーの在席状態を取得する Query を作成します。
typescript// すべてのユーザーの Presence 情報を取得
export const getAllPresence = query({
args: {},
handler: async (ctx) => {
// 最終更新時刻が5分以内のデータのみ取得
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
const presenceList = await ctx.db
.query('presence')
.filter((q) =>
q.gte(q.field('lastSeen'), fiveMinutesAgo)
)
.collect();
return presenceList;
},
});
この Query では、5 分以内に更新されたデータのみを取得することで、古い Presence 情報を除外しています。
特定ユーザーの Presence を取得
特定のユーザーの在席状態を取得する Query も実装しましょう。
typescript// 特定ユーザーの Presence 情報を取得
export const getUserPresence = query({
args: {
userId: v.string(),
},
handler: async (ctx, args) => {
const { userId } = args;
const presence = await ctx.db
.query('presence')
.withIndex('by_user', (q) => q.eq('userId', userId))
.first();
return presence || null;
},
});
インデックスを使用することで、ユーザー ID による検索が高速に実行されます。
クライアント側の実装
React コンポーネントで Convex の Presence 機能を利用します。
Convex クライアントのセットアップ
アプリケーションのルートコンポーネントで Convex クライアントを初期化します。
typescriptimport {
ConvexProvider,
ConvexReactClient,
} from 'convex/react';
まず、必要なライブラリをインポートします。
クライアントの初期化とプロバイダー設定
Convex クライアントを初期化し、アプリケーション全体をラップします。
typescriptconst convex = new ConvexReactClient(
process.env.NEXT_PUBLIC_CONVEX_URL!
);
function App() {
return (
<ConvexProvider client={convex}>
{/* アプリケーションのコンポーネント */}
<MainApp />
</ConvexProvider>
);
}
環境変数 NEXT_PUBLIC_CONVEX_URL
には、Convex プロジェクトの URL を設定します。この設定により、すべての子コンポーネントで Convex のフックが使用可能になりますよ。
Presence フックのカスタム実装
Presence 機能を便利に使うためのカスタムフックを作成します。
typescriptimport { useEffect } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { api } from '../convex/_generated/api';
Convex のフックと自動生成された API をインポートします。
カスタムフック本体
ユーザーの Presence を管理するカスタムフックを実装しましょう。
typescriptexport function usePresence(
userId: string,
status: string
) {
// Mutation 関数を取得
const updatePresence = useMutation(
api.presence.updatePresence
);
const heartbeat = useMutation(api.presence.heartbeat);
useEffect(() => {
// コンポーネントマウント時に在席状態を更新
updatePresence({ userId, status });
// 30秒ごとにハートビートを送信
const intervalId = setInterval(() => {
heartbeat({ userId });
}, 30 * 1000);
// クリーンアップ: オフライン状態に更新
return () => {
clearInterval(intervalId);
updatePresence({ userId, status: 'offline' });
};
}, [userId, status, updatePresence, heartbeat]);
}
このフックは、コンポーネントがマウントされたときに在席状態を「オンライン」に設定し、定期的にハートビートを送信します。アンマウント時には自動的に「オフライン」へ更新されるのです。
Presence 表示コンポーネント
他のユーザーの在席状態を表示するコンポーネントを作成します。
typescriptimport { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
export function PresenceIndicator() {
// すべてのユーザーの Presence 情報を購読
const presenceList = useQuery(api.presence.getAllPresence);
if (!presenceList) {
return <div>読み込み中...</div>;
}
useQuery
フックを使用すると、データベースの変更が自動的に反映され、リアルタイムで UI が更新されます。
Presence データの表示
取得した Presence データを UI に表示します。
typescript return (
<div>
<h2>在席状況</h2>
<ul>
{presenceList.map((presence) => (
<li key={presence._id}>
<span>{presence.userId}</span>
<span
style={{
color:
presence.status === "online"
? "green"
: presence.status === "away"
? "orange"
: "gray",
}}
>
{" "}
● {presence.status}
</span>
{presence.typingInRoom && (
<span> (入力中: {presence.typingInRoom})</span>
)}
</li>
))}
</ul>
</div>
);
}
各ユーザーの状態に応じて色分けして表示することで、視覚的にわかりやすくなりますね。
タイピングインジケーターの実装
チャットアプリでよく見られる「入力中...」表示を実装します。
タイピング状態の更新
入力フィールドで文字を入力したときに、タイピング状態を更新します。
typescriptimport { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
import { useState, useEffect } from "react";
export function ChatInput({ userId, roomId }: { userId: string; roomId: string }) {
const [message, setMessage] = useState("");
const updatePresence = useMutation(api.presence.updatePresence);
入力内容とステート管理、Mutation 関数を準備します。
タイピング検出ロジック
入力が開始されたらタイピング状態を通知し、一定時間入力がなければ解除します。
typescriptuseEffect(() => {
if (message.length > 0) {
// 入力中であることを通知
updatePresence({
userId,
status: 'online',
typingInRoom: roomId,
});
// 3秒後にタイピング状態を解除
const timeoutId = setTimeout(() => {
updatePresence({
userId,
status: 'online',
typingInRoom: undefined,
});
}, 3000);
return () => clearTimeout(timeoutId);
} else {
// 入力が空になったらタイピング状態を解除
updatePresence({
userId,
status: 'online',
typingInRoom: undefined,
});
}
}, [message, userId, roomId, updatePresence]);
この実装により、ユーザーが入力を開始すると他のユーザーに「入力中」と表示され、入力を停止すると自動的に解除されます。
入力フィールドの表示
実際の入力フィールドを表示します。
typescript return (
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="メッセージを入力..."
/>
);
}
シンプルな入力フィールドですが、裏側では Presence 情報がリアルタイムに更新されているのです。
古いデータの自動クリーンアップ
長期間更新されていない Presence データを定期的に削除する仕組みを実装します。
Cron ジョブの定義
Convex では、convex/crons.ts
ファイルで定期実行処理を定義できます。
typescriptimport { cronJobs } from 'convex/server';
import { internal } from './_generated/api';
const crons = cronJobs();
// 10分ごとに古い Presence データを削除
crons.interval(
'clean old presence',
{ minutes: 10 },
internal.presence.cleanOldPresence
);
export default crons;
この設定により、10 分ごとに cleanOldPresence
関数が自動実行されます。
クリーンアップ関数の実装
convex/presence.ts
に、古いデータを削除する内部関数を追加します。
typescriptimport { internalMutation } from "./_generated/server";
// 古い Presence データを削除する内部関数
export const cleanOldPresence = internalMutation({
args: {},
handler: async (ctx) => {
// 10分以上更新されていないデータを取得
const tenMinutesAgo = Date.now() - 10 * 60 * 1000;
const oldPresence = await ctx.db
.query("presence")
.withIndex("by_last_seen", (q) => q.lt("lastSeen", tenMinutesAgo))
.collect();
インデックスを活用することで、古いデータを効率的に検索できます。
データの削除処理
取得した古いデータを削除します。
typescript // 各レコードを削除
for (const presence of oldPresence) {
await ctx.db.delete(presence._id);
}
console.log(`Cleaned ${oldPresence.length} old presence records`);
},
});
定期的にクリーンアップすることで、データベースが肥大化せず、パフォーマンスを維持できますね。
環境変数の設定
Convex プロジェクトの URL を環境変数に設定します。
.env.local ファイルの作成
プロジェクトルートに .env.local
ファイルを作成します。
bashNEXT_PUBLIC_CONVEX_URL=https://your-project.convex.cloud
この URL は、yarn convex dev
を実行したときにターミナルに表示されます。
実装のまとめと動作確認
ここまでの実装で、以下の機能が完成しました。
# | 機能 | 説明 |
---|---|---|
1 | 在席状態の更新 | ユーザーがオンライン/オフラインになったときの状態変更 |
2 | ハートビート機構 | 30 秒ごとに接続状態を確認 |
3 | リアルタイム表示 | 他のユーザーの在席状態を自動更新 |
4 | タイピングインジケーター | 入力中の状態をリアルタイム表示 |
5 | 自動クリーンアップ | 古いデータを定期的に削除 |
動作確認の手順
以下の手順で動作を確認できます。
- 開発サーバーを起動:
yarn convex dev
とyarn dev
を別々のターミナルで実行 - ブラウザでアプリケーションを開く
- 別のブラウザまたはシークレットウィンドウで同じページを開く
- 一方のブラウザで入力を開始し、もう一方で「入力中」表示が現れることを確認
- ブラウザを閉じたときに、自動的にオフライン状態になることを確認
すべてがリアルタイムに同期され、遅延なく状態が反映されることが確認できるはずです。
下図は、実装した Presence 機能の動作フローを示したものです。
mermaidstateDiagram-v2
[*] --> Offline: アプリ起動前
Offline --> Online: アプリを開く
Online --> Typing: 入力開始
Typing --> Online: 3秒間入力なし
Online --> Away: 5分間操作なし
Away --> Online: 操作再開
Online --> Offline: アプリを閉じる
Offline --> [*]
note right of Online
ハートビート送信中
lastSeen を更新
end note
note right of Away
ハートビート停止
自動的に Away へ
end note
この状態遷移図から、ユーザーの行動に応じて Presence 状態がどのように変化するかが理解できますね。
まとめ
本記事では、Convex を使ってユーザーの在席状態をリアルタイムに同期する Presence 機能の実装方法を解説しました。
Convex のリアルタイムデータベースと Mutation、Query を組み合わせることで、WebSocket サーバーや Redis などのインフラを構築することなく、シンプルかつスケーラブルな実装が可能となります。スキーマ定義、状態更新、ハートビート機構、自動クリーンアップといった要素を段階的に実装することで、堅牢な Presence システムを構築できるのです。
特に、以下のポイントが重要でした。
- インデックスの活用:
userId
とlastSeen
にインデックスを設定することで、検索とクリーンアップを高速化 - ハートビート機構: 定期的に
lastSeen
を更新することで、接続状態を正確に追跡 - リアクティブクエリ:
useQuery
フックによる自動再実行で、常に最新の状態を表示 - 自動クリーンアップ: Cron ジョブで古いデータを削除し、パフォーマンスを維持
この実装をベースに、より高度な機能を追加することも可能です。例えば、ユーザーのアバター表示、最終ログイン時刻の表示、複数のステータス(会議中、休憩中など)のサポートといった拡張が考えられますね。
Convex の強力なリアルタイム同期機能を活用すれば、チャットアプリケーションやコラボレーションツールにおいて、ユーザー体験を大幅に向上させることができるでしょう。
関連リンク
- article
Convex で Presence(在席)機能を実装:ユーザーステータスのリアルタイム同期
- article
Convex で実践する CQRS/イベントソーシング:履歴・再生・集約の設計ガイド
- article
Convex クエリ/ミューテーション/アクション チートシート【保存版シンタックス】
- article
Convex 初期設定完全手順:CLI・環境変数・Secrets・権限までゼロから構築
- article
【比較検証】Convex vs Firebase vs Supabase:リアルタイム性・整合性・学習コストの最適解
- article
【2025 年最新】Convex の全体像を 10 分で理解:リアルタイム DB× 関数基盤の要点まとめ
- article
Convex で Presence(在席)機能を実装:ユーザーステータスのリアルタイム同期
- article
Next.js の RSC 境界設計:Client Components を最小化する責務分離戦略
- article
Mermaid 矢印・接続子チートシート:線種・方向・注釈の一覧早見
- article
Codex とは何か?AI コーディングの基礎・仕組み・適用範囲をやさしく解説
- article
MCP サーバー 設計ベストプラクティス:ツール定義、権限分離、スキーマ設計の要点まとめ
- article
Astro で動的 OG 画像を生成する:Satori/Canvas 連携の実装レシピ
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来