T-CREATOR

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

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 データを保存するテーブルスキーマの定義
3Mutation 実装状態更新とハートビート処理の関数実装
4Query 実装在席状態を取得するクエリ関数の実装
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 から、mutationquery 関数をインポートします。

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自動クリーンアップ古いデータを定期的に削除

動作確認の手順

以下の手順で動作を確認できます。

  1. 開発サーバーを起動: yarn convex devyarn dev を別々のターミナルで実行
  2. ブラウザでアプリケーションを開く
  3. 別のブラウザまたはシークレットウィンドウで同じページを開く
  4. 一方のブラウザで入力を開始し、もう一方で「入力中」表示が現れることを確認
  5. ブラウザを閉じたときに、自動的にオフライン状態になることを確認

すべてがリアルタイムに同期され、遅延なく状態が反映されることが確認できるはずです。

下図は、実装した 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 システムを構築できるのです。

特に、以下のポイントが重要でした。

  • インデックスの活用: userIdlastSeen にインデックスを設定することで、検索とクリーンアップを高速化
  • ハートビート機構: 定期的に lastSeen を更新することで、接続状態を正確に追跡
  • リアクティブクエリ: useQuery フックによる自動再実行で、常に最新の状態を表示
  • 自動クリーンアップ: Cron ジョブで古いデータを削除し、パフォーマンスを維持

この実装をベースに、より高度な機能を追加することも可能です。例えば、ユーザーのアバター表示、最終ログイン時刻の表示、複数のステータス(会議中、休憩中など)のサポートといった拡張が考えられますね。

Convex の強力なリアルタイム同期機能を活用すれば、チャットアプリケーションやコラボレーションツールにおいて、ユーザー体験を大幅に向上させることができるでしょう。

関連リンク