T-CREATOR

Convex の基本アーキテクチャ徹底解説:データベース・関数・リアルタイム更新

Convex の基本アーキテクチャ徹底解説:データベース・関数・リアルタイム更新

モダンな Web 開発において、フロントエンドとバックエンドの連携、リアルタイム機能の実装、データベース管理は常に課題となっています。これらの複雑な問題を一気に解決する革新的なプラットフォームが Convex です。

今回は、Convex の基本アーキテクチャを徹底解説し、データベース機能、サーバーレス関数、リアルタイム更新システムの 3 つの核心機能について詳しく説明いたします。実際のチャットアプリケーション構築を通じて、Convex の真価を体感していただけるでしょう。

Convex とは:モダンなフルスタック開発プラットフォーム

Convex は、現代の Web アプリケーション開発において必要な機能をオールインワンで提供するフルスタック開発プラットフォームです。従来のバックエンド開発で必要だった複雑な設定や管理作業を大幅に簡素化し、開発者がアプリケーションのロジックに集中できる環境を提供します。

Convex の革新性

Convex は単なるバックエンドサービスではありません。TypeScript ファーストのアプローチを採用し、フロントエンドからバックエンドまで一貫した開発体験を実現しています。

以下の図は、Convex が提供する開発体験の全体像を示しています。

mermaidflowchart TB
    dev[開発者] -->|TypeScript で記述| convex[Convex Platform]

    subgraph convex_platform[Convex Platform]
        db[(リアクティブ<br/>データベース)]
        functions[サーバーレス<br/>関数]
        realtime[リアルタイム<br/>更新システム]
    end

    convex -->|自動生成| client[型安全な<br/>クライアント API]
    client -->|React/Vue/etc| frontend[フロントエンド<br/>アプリケーション]

    db <--> functions
    functions <--> realtime
    realtime <--> frontend

この図からわかるように、Convex は開発者が TypeScript で記述したコードを基に、型安全なクライアント API を自動生成します。これにより、フロントエンドとバックエンド間の型の不整合を防ぎ、開発効率を大幅に向上させます。

従来開発との比較

項目従来の開発Convex
バックエンド設定複雑な設定とインフラ管理が必要設定不要、自動でスケーリング
型安全性フロント・バック間で型が分離一貫した TypeScript 型システム
リアルタイム機能WebSocket の手動実装が必要自動的なリアルタイム更新
データベース管理ORM 設定とマイグレーション管理スキーマレス、自動インデックス
API 設計REST/GraphQL の手動設計関数ベースの自動 API 生成

Convex の 3 つの核心機能

Convex の強力さは、以下の 3 つの核心機能が密接に連携することで実現されています。

データベース機能

Convex のデータベースは、NoSQL ドキュメントストアでありながら、SQL ライクなクエリ機能を提供します。スキーマレス設計により、開発初期の迅速な反復開発を可能にし、同時に型安全性も確保します。

サーバーレス関数

Query、Mutation、Action の 3 種類の関数により、データの取得、更新、外部 API 連携を型安全に実装できます。すべての関数は TypeScript で記述され、自動的にスケーリングされます。

リアルタイム更新システム

フロントエンドのコンポーネントがデータベースの変更を自動的に検知し、UI を更新します。WebSocket の複雑な実装は不要で、宣言的な記述だけでリアルタイム機能を実現できます。

以下の図は、これら 3 つの機能がどのように連携するかを示しています。

mermaidsequenceDiagram
    participant F as フロントエンド
    participant Q as Query関数
    participant M as Mutation関数
    participant DB as データベース
    participant RT as リアルタイム<br/>更新システム

    F->>Q: データ取得要求
    Q->>DB: クエリ実行
    DB-->>Q: データ返却
    Q-->>F: 型安全なデータ

    F->>M: データ更新要求
    M->>DB: データ更新実行
    DB->>RT: 変更通知
    RT->>F: 自動UI更新

この連携により、開発者は複雑な状態管理やキャッシュ制御を意識することなく、リアクティブなアプリケーションを構築できます。

データベースアーキテクチャの詳細

Convex のデータベースは、モダンな Web アプリケーションのニーズに最適化された設計となっています。スキーマレスでありながら型安全性を保ち、自動的な最適化機能を備えています。

スキーマ定義とデータモデル

Convex では、データの構造を TypeScript のインターフェースとして定義します。これにより、実行時の型安全性とコンパイル時のチェックを両立できます。

以下は、基本的なスキーマ定義の例です。

typescript// convex/schema.ts
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';

export default defineSchema({
  users: defineTable({
    name: v.string(),
    email: v.string(),
    avatar: v.optional(v.string()),
    createdAt: v.number(),
  }).index('by_email', ['email']),

  messages: defineTable({
    userId: v.id('users'),
    content: v.string(),
    timestamp: v.number(),
    channelId: v.string(),
  })
    .index('by_channel', ['channelId', 'timestamp'])
    .index('by_user', ['userId']),
});

このスキーマ定義から、Convex は自動的に型定義を生成し、クライアント側でも同じ型を使用できます。

スキーマ定義においては、以下のデータ型を使用できます。

説明使用例
v.string()文字列型ユーザー名、メッセージ内容
v.number()数値型タイムスタンプ、カウンター
v.boolean()真偽値型フラグ、設定値
v.id("table")他テーブルへの参照外部キー
v.optional()オプショナル型任意入力項目
v.array()配列型タグリスト、画像 URLs

クエリとインデックス設計

効率的なデータ取得のために、Convex では戦略的なインデックス設計が重要です。インデックスは、クエリのパフォーマンスを大幅に向上させます。

typescript// 効率的なクエリ関数の実装例
export const getMessagesByChannel = query({
  args: { channelId: v.string() },
  handler: async (ctx, args) => {
    // インデックスを使用した高速クエリ
    return await ctx.db
      .query('messages')
      .withIndex('by_channel', (q) =>
        q.eq('channelId', args.channelId)
      )
      .order('desc') // timestamp の降順
      .take(50); // 最新50件のみ取得
  },
});

インデックス設計のベストプラクティスは以下の通りです。

typescript// 複合インデックスの例
.index("by_channel_and_time", ["channelId", "timestamp"])

// 単一インデックスの例
.index("by_user", ["userId"])

// 検索用インデックスの例
.index("by_name", ["name"])

データの永続化と整合性

Convex は ACID トランザクションをサポートし、データの整合性を自動的に保証します。以下の図は、データの永続化プロセスを示しています。

mermaidflowchart TD
    client[クライアント要求] -->|Mutation実行| validation[バリデーション]
    validation -->|成功| transaction[トランザクション開始]
    validation -->|失敗| error[エラー返却]

    transaction --> update[データ更新]
    update --> index_update[インデックス更新]
    index_update --> commit[コミット実行]
    commit --> notify[変更通知]
    notify --> realtime[リアルタイム更新配信]

このプロセスにより、データの整合性が自動的に保たれ、開発者は複雑なトランザクション管理を意識する必要がありません。

関数システムの仕組み

Convex の関数システムは、Query、Mutation、Action の 3 種類の関数により構成されています。それぞれが特定の役割を持ち、組み合わせることで堅牢なアプリケーションを構築できます。

Query 関数の役割と実装

Query 関数は、データの読み取り専用操作を担当します。フロントエンドからリアルタイムに監視され、データが変更されると自動的に再実行されます。

typescript// convex/queries.ts
import { query } from './_generated/server';
import { v } from 'convex/values';

export const getUserProfile = query({
  args: { userId: v.id('users') },
  handler: async (ctx, args) => {
    // ユーザー情報の取得
    const user = await ctx.db.get(args.userId);
    if (!user) {
      throw new Error('ユーザーが見つかりません');
    }

    // 関連データの取得
    const messageCount = await ctx.db
      .query('messages')
      .withIndex('by_user', (q) =>
        q.eq('userId', args.userId)
      )
      .collect()
      .then((messages) => messages.length);

    return {
      ...user,
      messageCount,
    };
  },
});

Query 関数の特徴は以下の通りです。

特徴説明
読み取り専用データの変更は一切行えません
リアルタイム監視データ変更時に自動再実行されます
キャッシュ最適化結果が自動的にキャッシュされます
並列実行複数のクエリが同時実行可能です

Mutation 関数によるデータ操作

Mutation 関数は、データの作成、更新、削除を担当します。トランザクション内で実行され、データの整合性が保証されます。

typescript// convex/mutations.ts
import { mutation } from './_generated/server';
import { v } from 'convex/values';

export const createMessage = mutation({
  args: {
    content: v.string(),
    channelId: v.string(),
    userId: v.id('users'),
  },
  handler: async (ctx, args) => {
    // バリデーション
    if (args.content.trim().length === 0) {
      throw new Error('メッセージ内容が空です');
    }

    // ユーザーの存在確認
    const user = await ctx.db.get(args.userId);
    if (!user) {
      throw new Error('無効なユーザーです');
    }

    // メッセージの作成
    const messageId = await ctx.db.insert('messages', {
      content: args.content.trim(),
      channelId: args.channelId,
      userId: args.userId,
      timestamp: Date.now(),
    });

    return messageId;
  },
});

Mutation 関数の実装では、以下のパターンを推奨します。

typescript// エラーハンドリングのパターン
export const updateUserProfile = mutation({
  args: {
    userId: v.id('users'),
    name: v.string(),
    email: v.string(),
  },
  handler: async (ctx, args) => {
    try {
      // 入力値検証
      if (args.name.length < 2) {
        throw new Error(
          '名前は2文字以上である必要があります'
        );
      }

      // 重複チェック
      const existingUser = await ctx.db
        .query('users')
        .withIndex('by_email', (q) =>
          q.eq('email', args.email)
        )
        .first();

      if (
        existingUser &&
        existingUser._id !== args.userId
      ) {
        throw new Error(
          'このメールアドレスは既に使用されています'
        );
      }

      // データ更新
      await ctx.db.patch(args.userId, {
        name: args.name,
        email: args.email,
      });

      return { success: true };
    } catch (error) {
      console.error('プロフィール更新エラー:', error);
      throw error;
    }
  },
});

Action 関数と外部 API 連携

Action 関数は、外部 API との連携や非同期処理を担当します。データベースの読み書きも可能ですが、リアルタイム更新の対象外となります。

typescript// convex/actions.ts
import { action } from './_generated/server';
import { v } from 'convex/values';
import { api } from './_generated/api';

export const sendNotificationEmail = action({
  args: {
    userId: v.id('users'),
    message: v.string(),
  },
  handler: async (ctx, args) => {
    // ユーザー情報を取得
    const user = await ctx.runQuery(
      api.queries.getUserProfile,
      {
        userId: args.userId,
      }
    );

    if (!user?.email) {
      throw new Error(
        'ユーザーのメールアドレスが見つかりません'
      );
    }

    // 外部メールAPI(例:SendGrid)への通知
    const response = await fetch(
      'https://api.sendgrid.v3/mail/send',
      {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.SENDGRID_API_KEY}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          personalizations: [
            {
              to: [{ email: user.email }],
            },
          ],
          from: { email: 'noreply@example.com' },
          subject: '新しい通知',
          content: [
            {
              type: 'text/plain',
              value: args.message,
            },
          ],
        }),
      }
    );

    if (!response.ok) {
      throw new Error('メール送信に失敗しました');
    }

    // 送信履歴をデータベースに記録
    await ctx.runMutation(
      api.mutations.createNotificationLog,
      {
        userId: args.userId,
        type: 'email',
        status: 'sent',
        timestamp: Date.now(),
      }
    );

    return { success: true };
  },
});

Action 関数における外部 API 連携のベストプラクティスを以下に示します。

項目推奨事項
エラーハンドリングtry-catch でラップし、適切なエラーメッセージを返す
タイムアウト設定fetch に timeout を設定する
リトライ機能一時的な障害に対するリトライロジックを実装
ログ記録API 呼び出しの成功・失敗をログに記録
環境変数管理機密情報は環境変数で管理

リアルタイム更新の実現方法

Convex の最大の特徴の一つが、宣言的な記述だけで実現できるリアルタイム更新機能です。複雑な WebSocket 実装や状態管理は不要で、データの変更が自動的にフロントエンドに反映されます。

リアクティブクエリの仕組み

Convex のリアクティブクエリは、データベースの変更を監視し、関連するクエリを自動的に再実行します。

typescript// React コンポーネントでのリアクティブクエリ使用例
import { useQuery } from 'convex/react';
import { api } from '../convex/_generated/api';

function MessageList({ channelId }: { channelId: string }) {
  // データベースの変更を自動監視
  const messages = useQuery(
    api.queries.getMessagesByChannel,
    {
      channelId,
    }
  );

  // ローディング状態の処理
  if (messages === undefined) {
    return <div>メッセージを読み込み中...</div>;
  }

  return (
    <div>
      {messages.map((message) => (
        <div key={message._id}>
          <strong>{message.author}</strong>:{' '}
          {message.content}
        </div>
      ))}
    </div>
  );
}

リアクティブクエリの動作原理を以下の図で説明します。

mermaidsequenceDiagram
    participant C as クライアント
    participant CQ as Convex Query
    participant DB as データベース
    participant WS as WebSocket

    C->>CQ: useQuery でクエリ実行
    CQ->>DB: データ取得
    DB-->>CQ: 初期データ返却
    CQ-->>C: データとサブスクリプション

    Note over C,WS: 別のクライアントがデータ更新

    DB->>WS: 変更通知
    WS->>C: リアルタイム更新通知
    C->>CQ: 再クエリ実行
    CQ->>DB: 最新データ取得
    DB-->>CQ: 更新データ返却
    CQ-->>C: UI自動更新

WebSocket による双方向通信

Convex は内部で WebSocket を使用していますが、開発者が直接 WebSocket を扱う必要はありません。すべて抽象化されており、宣言的な API を通じて利用できます。

typescript// Mutation の実行とリアルタイム反映
import { useMutation } from 'convex/react';
import { api } from '../convex/_generated/api';

function MessageForm({
  channelId,
  userId,
}: {
  channelId: string;
  userId: string;
}) {
  const [content, setContent] = useState('');

  // Mutation 関数の使用
  const sendMessage = useMutation(
    api.mutations.createMessage
  );

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    try {
      // メッセージ送信(自動的にリアルタイム更新される)
      await sendMessage({
        content,
        channelId,
        userId,
      });

      setContent(''); // フォームクリア
    } catch (error) {
      console.error('メッセージ送信エラー:', error);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type='text'
        value={content}
        onChange={(e) => setContent(e.target.value)}
        placeholder='メッセージを入力...'
      />
      <button type='submit'>送信</button>
    </form>
  );
}

状態同期とキャッシュ戦略

Convex は、クライアント側で効率的なキャッシュ機能を提供し、ネットワーク使用量を最小限に抑えながら最新のデータを保持します。

以下の図は、Convex のキャッシュ戦略を示しています。

mermaidflowchart LR
    subgraph client[クライアント]
        cache[ローカルキャッシュ]
        ui[UI コンポーネント]
    end

    subgraph convex[Convex サーバー]
        query[Query 関数]
        db[(データベース)]
    end

    ui -->|useQuery| cache
    cache -->|キャッシュミス| query
    query --> db
    db -->|データ返却| cache
    cache -->|リアルタイム更新| ui

    db -->|変更通知| cache

キャッシュの最適化により、以下の利点が得られます。

利点説明
高速レスポンスキャッシュされたデータは即座に返却されます
帯域幅節約変更があった部分のみ更新されます
オフライン対応一時的なネットワーク断絶時もキャッシュから表示
自動同期ネットワーク復旧時に自動的に同期されます

楽観的更新の実装例を以下に示します。

typescript// 楽観的更新を使用したいいね機能
import { useOptimisticAction } from 'convex/react';
import { api } from '../convex/_generated/api';

function LikeButton({ messageId }: { messageId: string }) {
  const message = useQuery(api.queries.getMessage, {
    messageId,
  });

  // 楽観的更新を使用
  const toggleLike = useOptimisticAction(
    api.mutations.toggleLike,
    {
      optimisticUpdate: (localStore, args) => {
        // 即座にUIを更新(サーバー応答を待たない)
        const currentMessage = localStore.getQuery(
          api.queries.getMessage,
          {
            messageId: args.messageId,
          }
        );

        if (currentMessage) {
          localStore.setQuery(
            api.queries.getMessage,
            { messageId },
            {
              ...currentMessage,
              likes: currentMessage.liked
                ? currentMessage.likes - 1
                : currentMessage.likes + 1,
              liked: !currentMessage.liked,
            }
          );
        }
      },
    }
  );

  return (
    <button onClick={() => toggleLike({ messageId })}>
      {message?.liked ? '❤️' : '🤍'} {message?.likes || 0}
    </button>
  );
}

実践:チャットアプリケーションの構築

これまで学んだ Convex の機能を活用して、実際にリアルタイムチャットアプリケーションを構築してみましょう。この実践では、ユーザー管理、メッセージ送受信、リアルタイム更新のすべてを実装します。

プロジェクトの初期設定

最初に、Convex プロジェクトを作成し、必要な依存関係をインストールします。

bash# プロジェクトの作成
yarn create convex-app@latest chat-app --template react-ts

# プロジェクトディレクトリに移動
cd chat-app

# Convex の初期化
yarn convex dev

データスキーマの設計

チャットアプリケーションに必要なデータ構造を定義します。

typescript// convex/schema.ts
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';

export default defineSchema({
  // ユーザーテーブル
  users: defineTable({
    name: v.string(),
    email: v.string(),
    avatar: v.optional(v.string()),
    isOnline: v.boolean(),
    lastSeen: v.number(),
  }).index('by_email', ['email']),

  // チャンネルテーブル
  channels: defineTable({
    name: v.string(),
    description: v.optional(v.string()),
    createdBy: v.id('users'),
    isPrivate: v.boolean(),
    createdAt: v.number(),
  }),

  // メッセージテーブル
  messages: defineTable({
    content: v.string(),
    channelId: v.id('channels'),
    userId: v.id('users'),
    timestamp: v.number(),
    edited: v.optional(v.boolean()),
    editedAt: v.optional(v.number()),
  })
    .index('by_channel', ['channelId', 'timestamp'])
    .index('by_user', ['userId']),

  // チャンネルメンバーシップ
  memberships: defineTable({
    userId: v.id('users'),
    channelId: v.id('channels'),
    role: v.union(v.literal('admin'), v.literal('member')),
    joinedAt: v.number(),
  })
    .index('by_user', ['userId'])
    .index('by_channel', ['channelId']),
});

Query 関数の実装

データ取得用のクエリ関数を実装します。

typescript// convex/queries.ts
import { query } from './_generated/server';
import { v } from 'convex/values';

// チャンネル一覧の取得
export const getChannels = query({
  args: { userId: v.id('users') },
  handler: async (ctx, args) => {
    // ユーザーが参加しているチャンネルを取得
    const memberships = await ctx.db
      .query('memberships')
      .withIndex('by_user', (q) =>
        q.eq('userId', args.userId)
      )
      .collect();

    const channels = await Promise.all(
      memberships.map(async (membership) => {
        const channel = await ctx.db.get(
          membership.channelId
        );
        return {
          ...channel,
          role: membership.role,
        };
      })
    );

    return channels.filter((channel) => channel !== null);
  },
});

// チャンネル内のメッセージ取得
export const getMessages = query({
  args: {
    channelId: v.id('channels'),
    limit: v.optional(v.number()),
  },
  handler: async (ctx, args) => {
    const limit = args.limit || 50;

    const messages = await ctx.db
      .query('messages')
      .withIndex('by_channel', (q) =>
        q.eq('channelId', args.channelId)
      )
      .order('desc')
      .take(limit);

    // メッセージと送信者情報を結合
    const messagesWithUsers = await Promise.all(
      messages.map(async (message) => {
        const user = await ctx.db.get(message.userId);
        return {
          ...message,
          user: user
            ? { name: user.name, avatar: user.avatar }
            : null,
        };
      })
    );

    return messagesWithUsers.reverse();
  },
});

// オンラインユーザーの取得
export const getOnlineUsers = query({
  args: { channelId: v.id('channels') },
  handler: async (ctx, args) => {
    // チャンネルメンバーを取得
    const memberships = await ctx.db
      .query('memberships')
      .withIndex('by_channel', (q) =>
        q.eq('channelId', args.channelId)
      )
      .collect();

    // オンラインユーザーのみフィルタ
    const onlineUsers = await Promise.all(
      memberships.map(async (membership) => {
        const user = await ctx.db.get(membership.userId);
        if (user?.isOnline) {
          return {
            _id: user._id,
            name: user.name,
            avatar: user.avatar,
          };
        }
        return null;
      })
    );

    return onlineUsers.filter((user) => user !== null);
  },
});

Mutation 関数の実装

データ操作用のミューテーション関数を実装します。

typescript// convex/mutations.ts
import { mutation } from './_generated/server';
import { v } from 'convex/values';

// メッセージの送信
export const sendMessage = mutation({
  args: {
    content: v.string(),
    channelId: v.id('channels'),
    userId: v.id('users'),
  },
  handler: async (ctx, args) => {
    // メッセージ内容の検証
    const trimmedContent = args.content.trim();
    if (trimmedContent.length === 0) {
      throw new Error('メッセージが空です');
    }

    if (trimmedContent.length > 1000) {
      throw new Error(
        'メッセージが長すぎます(1000文字以内)'
      );
    }

    // ユーザーがチャンネルのメンバーかチェック
    const membership = await ctx.db
      .query('memberships')
      .withIndex('by_user', (q) =>
        q.eq('userId', args.userId)
      )
      .filter((q) =>
        q.eq(q.field('channelId'), args.channelId)
      )
      .first();

    if (!membership) {
      throw new Error(
        'このチャンネルにメッセージを送信する権限がありません'
      );
    }

    // メッセージを作成
    const messageId = await ctx.db.insert('messages', {
      content: trimmedContent,
      channelId: args.channelId,
      userId: args.userId,
      timestamp: Date.now(),
    });

    return messageId;
  },
});

// ユーザーのオンライン状態更新
export const updateUserStatus = mutation({
  args: {
    userId: v.id('users'),
    isOnline: v.boolean(),
  },
  handler: async (ctx, args) => {
    await ctx.db.patch(args.userId, {
      isOnline: args.isOnline,
      lastSeen: Date.now(),
    });
  },
});

// チャンネルの作成
export const createChannel = mutation({
  args: {
    name: v.string(),
    description: v.optional(v.string()),
    createdBy: v.id('users'),
    isPrivate: v.boolean(),
  },
  handler: async (ctx, args) => {
    // チャンネル名の検証
    if (args.name.trim().length === 0) {
      throw new Error('チャンネル名が空です');
    }

    // チャンネルの作成
    const channelId = await ctx.db.insert('channels', {
      name: args.name.trim(),
      description: args.description,
      createdBy: args.createdBy,
      isPrivate: args.isPrivate,
      createdAt: Date.now(),
    });

    // 作成者をチャンネルの管理者として追加
    await ctx.db.insert('memberships', {
      userId: args.createdBy,
      channelId,
      role: 'admin',
      joinedAt: Date.now(),
    });

    return channelId;
  },
});

フロントエンドコンポーネントの実装

React を使用してチャットアプリケーションの UI を実装します。

typescript// src/components/ChatRoom.tsx
import React, { useState, useEffect, useRef } from 'react';
import { useQuery, useMutation } from 'convex/react';
import { api } from '../../convex/_generated/api';
import { Id } from '../../convex/_generated/dataModel';

interface ChatRoomProps {
  channelId: Id<'channels'>;
  userId: Id<'users'>;
}

export function ChatRoom({
  channelId,
  userId,
}: ChatRoomProps) {
  const [newMessage, setNewMessage] = useState('');
  const messagesEndRef = useRef<HTMLDivElement>(null);

  // リアルタイムでメッセージを取得
  const messages = useQuery(api.queries.getMessages, {
    channelId,
  });
  const onlineUsers = useQuery(api.queries.getOnlineUsers, {
    channelId,
  });

  // メッセージ送信のMutation
  const sendMessage = useMutation(
    api.mutations.sendMessage
  );

  // 自動スクロール
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({
      behavior: 'smooth',
    });
  }, [messages]);

  const handleSendMessage = async (e: React.FormEvent) => {
    e.preventDefault();

    if (newMessage.trim()) {
      try {
        await sendMessage({
          content: newMessage,
          channelId,
          userId,
        });
        setNewMessage('');
      } catch (error) {
        console.error('メッセージ送信エラー:', error);
        alert('メッセージの送信に失敗しました');
      }
    }
  };

  return (
    <div className='chat-room'>
      {/* オンラインユーザー表示 */}
      <div className='online-users'>
        <h3>オンライン ({onlineUsers?.length || 0})</h3>
        {onlineUsers?.map((user) => (
          <div key={user._id} className='user-item'>
            <div className='avatar'>
              {user.avatar ? (
                <img src={user.avatar} alt={user.name} />
              ) : (
                <div className='default-avatar'>
                  {user.name.charAt(0).toUpperCase()}
                </div>
              )}
            </div>
            <span>{user.name}</span>
          </div>
        ))}
      </div>

      {/* メッセージ表示エリア */}
      <div className='messages-container'>
        {messages === undefined ? (
          <div className='loading'>
            メッセージを読み込み中...
          </div>
        ) : (
          <>
            {messages.map((message) => (
              <div key={message._id} className='message'>
                <div className='message-header'>
                  <span className='username'>
                    {message.user?.name || 'Unknown User'}
                  </span>
                  <span className='timestamp'>
                    {new Date(
                      message.timestamp
                    ).toLocaleTimeString()}
                  </span>
                </div>
                <div className='message-content'>
                  {message.content}
                </div>
              </div>
            ))}
            <div ref={messagesEndRef} />
          </>
        )}
      </div>

      {/* メッセージ入力フォーム */}
      <form
        onSubmit={handleSendMessage}
        className='message-form'
      >
        <input
          type='text'
          value={newMessage}
          onChange={(e) => setNewMessage(e.target.value)}
          placeholder='メッセージを入力...'
          maxLength={1000}
          className='message-input'
        />
        <button
          type='submit'
          disabled={!newMessage.trim()}
          className='send-button'
        >
          送信
        </button>
      </form>
    </div>
  );
}

チャットアプリケーションの動作フローを以下の図で示します。

mermaidsequenceDiagram
    participant U1 as ユーザー1
    participant U2 as ユーザー2
    participant UI1 as UI1(React)
    participant UI2 as UI2(React)
    participant M as Mutation
    participant DB as データベース
    participant Q as Query

    U1->>UI1: メッセージ入力
    UI1->>M: sendMessage()
    M->>DB: メッセージ保存

    DB->>Q: 変更通知
    Q->>UI1: 自動更新
    Q->>UI2: 自動更新

    UI1->>U1: 新メッセージ表示
    UI2->>U2: 新メッセージ表示(リアルタイム)

この実装により、複数のユーザーがリアルタイムでメッセージをやり取りできるチャットアプリケーションが完成します。Convex の強力なリアルタイム機能により、WebSocket の実装や State 管理の複雑さを一切考慮することなく、直感的なコードでリアルタイム機能を実現できました。

まとめ

この記事では、Convex の基本アーキテクチャについて詳しく解説し、実際のチャットアプリケーション構築を通じてその実力を体験いたしました。

Convex の 3 つの核心機能(データベース、サーバーレス関数、リアルタイム更新)は、それぞれが独立して優秀でありながら、組み合わせることで従来の Web 開発では実現困難だった開発体験を提供します。

Convex の主な利点

  • 開発効率の向上: TypeScript ファーストのアプローチにより、型安全性を保ちながら迅速な開発が可能です
  • インフラ管理の不要: サーバー管理、スケーリング、監視が自動化されています
  • リアルタイム機能の簡単実装: 複雑な WebSocket 実装なしにリアルタイム機能を実現できます
  • データ整合性の保証: ACID トランザクションにより、データの整合性が自動的に保たれます

今後の学習ステップ

  1. 認証システムの統合: Convex Auth を使用したユーザー認証の実装
  2. ファイルストレージ: 画像やファイルのアップロード機能の追加
  3. バックグラウンド処理: 定期実行やキューシステムの活用
  4. パフォーマンス最適化: 大規模データに対するクエリ最適化手法

Convex は、モダンな Web アプリケーション開発において新たな可能性を切り開くプラットフォームです。従来の複雑な設定や管理作業から解放され、アプリケーションのコアロジックに集中できる環境を提供しています。

関連リンク