T-CREATOR

Convex × React/Next.js 最速連携:useQuery/useMutation の実践パターン

Convex × React/Next.js 最速連携:useQuery/useMutation の実践パターン

現代の Web アプリケーション開発において、フロントエンドとバックエンドの連携をスムーズに行うことは非常に重要です。そんな中で注目を集めているのが、Convex というリアルタイムデータベースプラットフォームです。

React や Next.js と組み合わせることで、従来の複雑な API 開発から解放され、直感的でパワフルなアプリケーションを素早く構築できるようになります。本記事では、Convex の useQuery と useMutation を使った実践的な開発パターンを、基礎から応用まで段階的に解説していきます。

背景

従来の API 開発の課題

従来の Web 開発では、フロントエンドとバックエンドを分離し、REST API や GraphQL を通じてデータをやり取りしていました。しかし、この手法には以下のような課題が存在していました。

mermaidflowchart TD
    Frontend[フロントエンド] -->|HTTP リクエスト| API[REST API]
    API -->|レスポンス| Frontend
    API -->|クエリ| DB[(データベース)]
    DB -->|結果| API

    Frontend -.->|状態管理の複雑さ| State[状態管理ライブラリ]
    Frontend -.->|キャッシュ管理| Cache[キャッシュシステム]
    Frontend -.->|エラーハンドリング| Error[エラー処理]

従来のアーキテクチャでは、データの取得から表示まで多くの中間処理が必要でした。

主な課題点

#課題詳細
1状態管理の複雑さサーバーデータとクライアント状態の同期が困難
2ボイラープレートコードAPI 呼び出し、ローディング、エラーハンドリングの反復実装
3リアルタイム実装の困難さWebSocket や SSE の複雑な実装が必要
4キャッシュ戦略データの整合性を保ちながらのキャッシュ管理

リアルタイム機能実装の複雑さ

特にリアルタイム機能の実装は、従来の手法では非常に複雑になりがちでした。WebSocket や Server-Sent Events を使用する場合、以下のような問題に直面することが多々ありました。

mermaidsequenceDiagram
    participant Client1 as クライアント1
    participant Client2 as クライアント2
    participant Server as サーバー
    participant DB as データベース

    Client1->>Server: WebSocket接続
    Client2->>Server: WebSocket接続
    Client1->>Server: データ更新リクエスト
    Server->>DB: データ更新
    DB-->>Server: 更新完了
    Server->>Client1: 更新結果通知
    Server->>Client2: リアルタイム更新通知

    Note over Server: 接続管理・状態同期・エラーハンドリングが複雑

リアルタイム機能では、複数クライアント間での状態同期と接続管理が大きな課題となります。

これらの課題を解決するため、Convex のような新しいアプローチが注目されているのです。

Convex 環境構築

Convex プロジェクト初期設定

まずは、Convex プロジェクトの初期設定から始めましょう。Convex を使用するには、専用の CLI ツールをインストールし、プロジェクトを初期化する必要があります。

Convex CLI のインストール

bash# Convex CLIをグローバルにインストール
yarn global add convex

# または npm を使用する場合
npm install -g convex

新しい Convex プロジェクトの作成

bash# 新しいディレクトリを作成してプロジェクトを初期化
mkdir my-convex-app
cd my-convex-app
convex init

Convex プロジェクトを初期化すると、以下のようなディレクトリ構造が作成されます。

typescript// プロジェクト構造の例
my-convex-app/
├── convex/
│   ├── _generated/
│   ├── schema.ts
│   └── tsconfig.json
├── .env.local
└── convex.json

スキーマの定義

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(),
    createdAt: v.number(),
  }).index('by_email', ['email']),

  // 投稿のテーブル
  posts: defineTable({
    title: v.string(),
    content: v.string(),
    authorId: v.id('users'),
    isPublished: v.boolean(),
    createdAt: v.number(),
  })
    .index('by_author', ['authorId'])
    .index('by_published', ['isPublished']),
});

この設定により、型安全なデータベース操作が可能になります。

Next.js アプリケーションとの連携設定

次に、Next.js アプリケーションと Convex を連携させる設定を行います。

必要なパッケージのインストール

bash# Next.jsプロジェクトにConvexクライアントをインストール
yarn add convex react

# 開発用の型定義もインストール
yarn add -D @types/react

Convex プロバイダーのセットアップ

Next.js アプリケーションで Convex を使用するために、プロバイダーを設定します。

typescript// pages/_app.tsx (Pages Router の場合)
import {
  ConvexProvider,
  ConvexReactClient,
} from 'convex/react';
import type { AppProps } from 'next/app';

// Convexクライアントの初期化
const convex = new ConvexReactClient(
  process.env.NEXT_PUBLIC_CONVEX_URL!
);

export default function App({
  Component,
  pageProps,
}: AppProps) {
  return (
    <ConvexProvider client={convex}>
      <Component {...pageProps} />
    </ConvexProvider>
  );
}

App Router を使用している場合は、以下のように設定します。

typescript// app/layout.tsx (App Router の場合)
'use client';

import {
  ConvexProvider,
  ConvexReactClient,
} from 'convex/react';

const convex = new ConvexReactClient(
  process.env.NEXT_PUBLIC_CONVEX_URL!
);

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang='ja'>
      <body>
        <ConvexProvider client={convex}>
          {children}
        </ConvexProvider>
      </body>
    </html>
  );
}

環境変数の設定

Convex の接続情報を環境変数に設定します。

bash# .env.local
NEXT_PUBLIC_CONVEX_URL=https://your-convex-deployment-url

この設定により、Next.js アプリケーションから Convex のデータベースに安全にアクセスできるようになります。

mermaidflowchart LR
    NextJS[Next.js アプリ] -->|ConvexProvider| Provider[Convex プロバイダー]
    Provider -->|クライアント| Client[Convex クライアント]
    Client -->|HTTPS| Convex[Convex バックエンド]
    Convex -->|データ同期| Database[(Convex DB)]

Convex プロバイダーを通じて、アプリケーション全体で Convex のリアルタイム機能を活用できます。

これで、Convex と Next.js の基本的な連携設定が完了しました。次のセクションでは、実際にデータを取得する useQuery の使い方を詳しく見ていきましょう。

useQuery の基本実装

データ取得パターン

Convex の useQuery は、従来の API 呼び出しと比べて非常にシンプルで直感的です。リアルタイムでデータが更新され、キャッシュやローディング状態も自動的に管理されます。

基本的なクエリ関数の作成

まず、Convex のクエリ関数を定義します。これらの関数はサーバーサイドで実行され、データベースから情報を取得します。

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

// 全ての投稿を取得するクエリ
export const getAllPosts = query({
  args: {},
  handler: async (ctx) => {
    return await ctx.db
      .query('posts')
      .filter((q) => q.eq(q.field('isPublished'), true))
      .order('desc')
      .collect();
  },
});

// 特定の投稿を取得するクエリ
export const getPostById = query({
  args: { postId: v.id('posts') },
  handler: async (ctx, args) => {
    return await ctx.db.get(args.postId);
  },
});

コンポーネントでの useQuery 使用

定義したクエリ関数を React コンポーネントで使用します。

typescript// components/PostList.tsx
import { useQuery } from 'convex/react';
import { api } from '../convex/_generated/api';

export function PostList() {
  // useQueryを使用してデータを取得
  const posts = useQuery(api.posts.getAllPosts);

  // データがまだ読み込まれていない場合
  if (posts === undefined) {
    return <div>読み込み中...</div>;
  }

  return (
    <div className='post-list'>
      <h2>投稿一覧</h2>
      {posts.map((post) => (
        <article key={post._id} className='post-item'>
          <h3>{post.title}</h3>
          <p>{post.content}</p>
          <time>
            {new Date(post.createdAt).toLocaleDateString()}
          </time>
        </article>
      ))}
    </div>
  );
}

パラメータ付きクエリの実装

動的なパラメータを使用したクエリも簡単に実装できます。

typescript// components/PostDetail.tsx
import { useQuery } from 'convex/react';
import { api } from '../convex/_generated/api';
import { Id } from '../convex/_generated/dataModel';

interface PostDetailProps {
  postId: Id<'posts'>;
}

export function PostDetail({ postId }: PostDetailProps) {
  const post = useQuery(api.posts.getPostById, { postId });

  if (post === undefined) {
    return <div>投稿を読み込み中...</div>;
  }

  if (post === null) {
    return <div>投稿が見つかりません</div>;
  }

  return (
    <article className='post-detail'>
      <h1>{post.title}</h1>
      <div className='post-content'>{post.content}</div>
    </article>
  );
}

ローディング・エラーハンドリング

Convex の useQuery では、ローディング状態とエラーハンドリングが組み込まれています。

ローディング状態の管理

typescript// components/PostListWithLoading.tsx
import { useQuery } from 'convex/react';
import { api } from '../convex/_generated/api';

export function PostListWithLoading() {
  const posts = useQuery(api.posts.getAllPosts);

  // undefinedはローディング中を示す
  if (posts === undefined) {
    return (
      <div className='loading-container'>
        <div className='spinner'>読み込み中...</div>
        <p>投稿データを取得しています</p>
      </div>
    );
  }

  // 空の配列の場合
  if (posts.length === 0) {
    return (
      <div className='empty-state'>
        <p>まだ投稿がありません</p>
        <button>最初の投稿を作成する</button>
      </div>
    );
  }

  return (
    <div className='post-list'>
      {posts.map((post) => (
        <PostCard key={post._id} post={post} />
      ))}
    </div>
  );
}

条件付きクエリとエラーハンドリング

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

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

    // ユーザーの投稿を取得
    return await ctx.db
      .query('posts')
      .withIndex('by_author', (q) =>
        q.eq('authorId', args.userId)
      )
      .collect();
  },
});
typescript// components/UserPosts.tsx
import { useQuery } from 'convex/react';
import { api } from '../convex/_generated/api';
import { Id } from '../convex/_generated/dataModel';

interface UserPostsProps {
  userId: Id<'users'> | null;
}

export function UserPosts({ userId }: UserPostsProps) {
  // userIdがnullの場合はクエリを実行しない
  const posts = useQuery(
    api.users.getUserPosts,
    userId ? { userId } : 'skip'
  );

  if (!userId) {
    return <div>ユーザーを選択してください</div>;
  }

  if (posts === undefined) {
    return <div>投稿を読み込み中...</div>;
  }

  return (
    <div className='user-posts'>
      <h3>ユーザーの投稿 ({posts.length}件)</h3>
      {posts.map((post) => (
        <div key={post._id} className='post-summary'>
          <h4>{post.title}</h4>
          <p>{post.content.substring(0, 100)}...</p>
        </div>
      ))}
    </div>
  );
}

キャッシュ活用法

Convex の useQuery は自動的にキャッシュを管理し、データが変更されると即座にコンポーネントが再レンダリングされます。

mermaidflowchart TD
    Component1[コンポーネント1] -->|useQuery| Cache[Convex キャッシュ]
    Component2[コンポーネント2] -->|useQuery| Cache
    Component3[コンポーネント3] -->|useQuery| Cache

    Cache <-->|リアルタイム同期| ConvexDB[(Convex データベース)]

    ConvexDB -->|データ更新| Cache
    Cache -->|自動再レンダリング| Component1
    Cache -->|自動再レンダリング| Component2
    Cache -->|自動再レンダリング| Component3

複数のコンポーネントが同じデータを参照していても、Convex が効率的にキャッシュを管理します。

効率的なクエリ設計

typescript// convex/posts.ts - 効率的なクエリの例
import { query } from './_generated/server';
import { v } from 'convex/values';

// ページネーション対応のクエリ
export const getPostsPaginated = query({
  args: {
    paginationOpts: v.object({
      numItems: v.number(),
      cursor: v.optional(v.string()),
    }),
  },
  handler: async (ctx, args) => {
    return await ctx.db
      .query('posts')
      .filter((q) => q.eq(q.field('isPublished'), true))
      .order('desc')
      .paginate(args.paginationOpts);
  },
});

// フィルタリング機能付きクエリ
export const getPostsByCategory = query({
  args: {
    category: v.string(),
    limit: v.optional(v.number()),
  },
  handler: async (ctx, args) => {
    let query = ctx.db
      .query('posts')
      .filter((q) =>
        q.and(
          q.eq(q.field('isPublished'), true),
          q.eq(q.field('category'), args.category)
        )
      )
      .order('desc');

    if (args.limit) {
      query = query.take(args.limit);
    }

    return await query.collect();
  },
});

これらのパターンを活用することで、効率的で保守しやすいデータ取得ロジックを構築できます。次のセクションでは、データの更新を行う useMutation の実装方法を詳しく解説します。

useMutation の基本実装

データ更新パターン

Convex の useMutation は、データベースの状態を変更する操作を安全かつ効率的に実行できます。リアルタイムでの更新反映や楽観的更新にも対応しています。

基本的なミューテーション関数の作成

まず、データを更新するためのミューテーション関数を定義します。

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

// 新しい投稿を作成するミューテーション
export const createPost = mutation({
  args: {
    title: v.string(),
    content: v.string(),
    authorId: v.id('users'),
  },
  handler: async (ctx, args) => {
    const postId = await ctx.db.insert('posts', {
      title: args.title,
      content: args.content,
      authorId: args.authorId,
      isPublished: false,
      createdAt: Date.now(),
    });
    return postId;
  },
});

// 投稿を更新するミューテーション
export const updatePost = mutation({
  args: {
    postId: v.id('posts'),
    title: v.optional(v.string()),
    content: v.optional(v.string()),
    isPublished: v.optional(v.boolean()),
  },
  handler: async (ctx, args) => {
    const { postId, ...updates } = args;
    await ctx.db.patch(postId, updates);
    return postId;
  },
});

コンポーネントでの useMutation 使用

定義したミューテーション関数を React コンポーネントで使用します。

typescript// components/PostForm.tsx
import { useState } from 'react';
import { useMutation } from 'convex/react';
import { api } from '../convex/_generated/api';
import { Id } from '../convex/_generated/dataModel';

interface PostFormProps {
  authorId: Id<'users'>;
  onSuccess?: () => void;
}

export function PostForm({
  authorId,
  onSuccess,
}: PostFormProps) {
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);

  // useMutationでミューテーション関数を取得
  const createPost = useMutation(api.posts.createPost);

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

    try {
      // ミューテーションを実行
      const postId = await createPost({
        title,
        content,
        authorId,
      });

      console.log('投稿が作成されました:', postId);

      // フォームをリセット
      setTitle('');
      setContent('');
      onSuccess?.();
    } catch (error) {
      console.error('投稿の作成に失敗しました:', error);
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit} className='post-form'>
      <div className='form-group'>
        <label htmlFor='title'>タイトル</label>
        <input
          id='title'
          type='text'
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          required
          disabled={isSubmitting}
        />
      </div>

      <div className='form-group'>
        <label htmlFor='content'>内容</label>
        <textarea
          id='content'
          value={content}
          onChange={(e) => setContent(e.target.value)}
          required
          disabled={isSubmitting}
          rows={6}
        />
      </div>

      <button
        type='submit'
        disabled={
          isSubmitting || !title.trim() || !content.trim()
        }
      >
        {isSubmitting ? '投稿中...' : '投稿する'}
      </button>
    </form>
  );
}

複数のミューテーションを使用した CRUD 操作

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

interface PostEditorProps {
  postId: Id<'posts'>;
}

export function PostEditor({ postId }: PostEditorProps) {
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');
  const [isPublished, setIsPublished] = useState(false);

  // データ取得
  const post = useQuery(api.posts.getPostById, { postId });

  // ミューテーション関数
  const updatePost = useMutation(api.posts.updatePost);
  const deletePost = useMutation(api.posts.deletePost);

  // 投稿データが取得できたらフォームに反映
  useEffect(() => {
    if (post) {
      setTitle(post.title);
      setContent(post.content);
      setIsPublished(post.isPublished);
    }
  }, [post]);

  const handleUpdate = async () => {
    try {
      await updatePost({
        postId,
        title,
        content,
        isPublished,
      });
      alert('投稿が更新されました');
    } catch (error) {
      alert('更新に失敗しました');
    }
  };

  const handleDelete = async () => {
    if (confirm('本当に削除しますか?')) {
      try {
        await deletePost({ postId });
        alert('投稿が削除されました');
      } catch (error) {
        alert('削除に失敗しました');
      }
    }
  };

  if (post === undefined) return <div>読み込み中...</div>;
  if (post === null) return <div>投稿が見つかりません</div>;

  return (
    <div className='post-editor'>
      <h2>投稿を編集</h2>

      <div className='form-group'>
        <input
          type='text'
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder='タイトル'
        />
      </div>

      <div className='form-group'>
        <textarea
          value={content}
          onChange={(e) => setContent(e.target.value)}
          placeholder='内容'
          rows={8}
        />
      </div>

      <div className='form-group'>
        <label>
          <input
            type='checkbox'
            checked={isPublished}
            onChange={(e) =>
              setIsPublished(e.target.checked)
            }
          />
          公開する
        </label>
      </div>

      <div className='form-actions'>
        <button onClick={handleUpdate}>更新</button>
        <button onClick={handleDelete} className='danger'>
          削除
        </button>
      </div>
    </div>
  );
}

楽観的更新の実装

楽観的更新は、ユーザー体験を向上させる重要なテクニックです。Convex では、ミューテーションの結果を待たずに UI を即座に更新できます。

typescript// convex/posts.ts
export const toggleLike = mutation({
  args: {
    postId: v.id('posts'),
    userId: v.id('users'),
  },
  handler: async (ctx, args) => {
    const existingLike = await ctx.db
      .query('likes')
      .withIndex('by_post_user', (q) =>
        q
          .eq('postId', args.postId)
          .eq('userId', args.userId)
      )
      .first();

    if (existingLike) {
      // いいねを取り消し
      await ctx.db.delete(existingLike._id);
      return { action: 'unliked' };
    } else {
      // いいねを追加
      await ctx.db.insert('likes', {
        postId: args.postId,
        userId: args.userId,
        createdAt: Date.now(),
      });
      return { action: 'liked' };
    }
  },
});
typescript// components/LikeButton.tsx
import { useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { api } from '../convex/_generated/api';
import { Id } from '../convex/_generated/dataModel';

interface LikeButtonProps {
  postId: Id<'posts'>;
  userId: Id<'users'>;
}

export function LikeButton({
  postId,
  userId,
}: LikeButtonProps) {
  const [isOptimisticLiked, setIsOptimisticLiked] =
    useState<boolean | null>(null);

  // 現在のいいね状態を取得
  const isLiked = useQuery(api.posts.isLikedByUser, {
    postId,
    userId,
  });
  const likeCount = useQuery(api.posts.getLikeCount, {
    postId,
  });

  const toggleLike = useMutation(api.posts.toggleLike);

  // 楽観的更新を考慮した表示状態
  const displayIsLiked =
    isOptimisticLiked !== null
      ? isOptimisticLiked
      : isLiked;

  const handleToggleLike = async () => {
    // 楽観的更新:即座にUIを更新
    setIsOptimisticLiked(!displayIsLiked);

    try {
      await toggleLike({ postId, userId });
      // 成功時は楽観的更新状態をリセット
      setIsOptimisticLiked(null);
    } catch (error) {
      // エラー時は楽観的更新を元に戻す
      setIsOptimisticLiked(null);
      console.error('いいねの更新に失敗しました:', error);
    }
  };

  return (
    <button
      onClick={handleToggleLike}
      className={`like-button ${
        displayIsLiked ? 'liked' : ''
      }`}
      disabled={isLiked === undefined}
    >
      <span className='like-icon'>
        {displayIsLiked ? '❤️' : '🤍'}
      </span>
      <span className='like-count'>{likeCount || 0}</span>
    </button>
  );
}

バリデーション連携

Convex では、Zod や Yup などのバリデーションライブラリと連携して、データの整合性を保証できます。

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

export const createPostWithValidation = mutation({
  args: {
    title: v.string(),
    content: v.string(),
    authorId: v.id('users'),
  },
  handler: async (ctx, args) => {
    // バリデーション
    if (args.title.trim().length === 0) {
      throw new Error('タイトルは必須です');
    }

    if (args.title.length > 100) {
      throw new Error(
        'タイトルは100文字以内で入力してください'
      );
    }

    if (args.content.trim().length === 0) {
      throw new Error('内容は必須です');
    }

    if (args.content.length > 5000) {
      throw new Error(
        '内容は5000文字以内で入力してください'
      );
    }

    // 著者の存在確認
    const author = await ctx.db.get(args.authorId);
    if (!author) {
      throw new Error('指定された著者が見つかりません');
    }

    // 投稿の作成
    const postId = await ctx.db.insert('posts', {
      title: args.title.trim(),
      content: args.content.trim(),
      authorId: args.authorId,
      isPublished: false,
      createdAt: Date.now(),
    });

    return postId;
  },
});
mermaidflowchart TD
    Form[フォーム入力] -->|バリデーション| Validation{クライアント<br/>バリデーション}
    Validation -->|NG| Error1[エラー表示]
    Validation -->|OK| Mutation[useMutation実行]
    Mutation -->|サーバー処理| ServerValidation{サーバー<br/>バリデーション}
    ServerValidation -->|NG| Error2[サーバーエラー]
    ServerValidation -->|OK| Database[(データベース更新)]
    Database -->|成功| Success[更新完了]
    Database -->|失敗| Error3[DB エラー]

クライアントとサーバーの両方でバリデーションを行うことで、データの整合性と安全性を確保できます。

このような実装パターンにより、useMutation を使って安全で高性能なデータ更新機能を構築できます。次のセクションでは、これらの基礎技術を活用した実践的なアプリケーション例を見ていきましょう。

実践的な活用例

CRUD 操作の完全実装

ここでは、ブログ管理システムを例に、Convex を使用した完全な CRUD 操作を実装します。

データスキーマの設計

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(),
    avatarUrl: v.optional(v.string()),
    createdAt: v.number(),
  }).index('by_email', ['email']),

  posts: defineTable({
    title: v.string(),
    content: v.string(),
    excerpt: v.string(),
    authorId: v.id('users'),
    isPublished: v.boolean(),
    publishedAt: v.optional(v.number()),
    createdAt: v.number(),
    updatedAt: v.number(),
  })
    .index('by_author', ['authorId'])
    .index('by_published', ['isPublished', 'publishedAt']),

  comments: defineTable({
    postId: v.id('posts'),
    authorId: v.id('users'),
    content: v.string(),
    createdAt: v.number(),
  }).index('by_post', ['postId']),
});

完全な CRUD API の実装

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

// CREATE: 投稿作成
export const createPost = mutation({
  args: {
    title: v.string(),
    content: v.string(),
    excerpt: v.string(),
    authorId: v.id('users'),
  },
  handler: async (ctx, args) => {
    const postId = await ctx.db.insert('posts', {
      ...args,
      isPublished: false,
      createdAt: Date.now(),
      updatedAt: Date.now(),
    });

    return await ctx.db.get(postId);
  },
});

// READ: 投稿一覧取得
export const getPosts = query({
  args: {
    published: v.optional(v.boolean()),
    authorId: v.optional(v.id('users')),
    limit: v.optional(v.number()),
  },
  handler: async (ctx, args) => {
    let query = ctx.db.query('posts');

    if (args.published !== undefined) {
      query = query.withIndex('by_published', (q) =>
        q.eq('isPublished', args.published)
      );
    }

    if (args.authorId) {
      query = query.withIndex('by_author', (q) =>
        q.eq('authorId', args.authorId)
      );
    }

    const posts = await query
      .order('desc')
      .take(args.limit || 20)
      .collect();

    // 著者情報も含めて返す
    return await Promise.all(
      posts.map(async (post) => {
        const author = await ctx.db.get(post.authorId);
        return { ...post, author };
      })
    );
  },
});

// UPDATE: 投稿更新
export const updatePost = mutation({
  args: {
    postId: v.id('posts'),
    title: v.optional(v.string()),
    content: v.optional(v.string()),
    excerpt: v.optional(v.string()),
    isPublished: v.optional(v.boolean()),
  },
  handler: async (ctx, args) => {
    const { postId, ...updates } = args;

    // 公開状態が変更された場合の処理
    if (updates.isPublished === true) {
      updates.publishedAt = Date.now();
    }

    updates.updatedAt = Date.now();

    await ctx.db.patch(postId, updates);
    return await ctx.db.get(postId);
  },
});

// DELETE: 投稿削除
export const deletePost = mutation({
  args: { postId: v.id('posts') },
  handler: async (ctx, args) => {
    // 関連するコメントも削除
    const comments = await ctx.db
      .query('comments')
      .withIndex('by_post', (q) =>
        q.eq('postId', args.postId)
      )
      .collect();

    for (const comment of comments) {
      await ctx.db.delete(comment._id);
    }

    // 投稿を削除
    await ctx.db.delete(args.postId);
    return { success: true };
  },
});

フロントエンド実装

typescript// components/BlogManager.tsx
import { useState } from 'react';
import { useQuery, useMutation } from 'convex/react';
import { api } from '../convex/_generated/api';
import { Id } from '../convex/_generated/dataModel';

export function BlogManager({
  userId,
}: {
  userId: Id<'users'>;
}) {
  const [selectedPost, setSelectedPost] =
    useState<Id<'posts'> | null>(null);
  const [isCreating, setIsCreating] = useState(false);

  // データ取得
  const posts = useQuery(api.posts.getPosts, {
    authorId: userId,
  });
  const selectedPostData = useQuery(
    api.posts.getPostById,
    selectedPost ? { postId: selectedPost } : 'skip'
  );

  // ミューテーション
  const createPost = useMutation(api.posts.createPost);
  const updatePost = useMutation(api.posts.updatePost);
  const deletePost = useMutation(api.posts.deletePost);

  const handleCreatePost = async (data: {
    title: string;
    content: string;
    excerpt: string;
  }) => {
    try {
      const newPost = await createPost({
        ...data,
        authorId: userId,
      });
      setSelectedPost(newPost._id);
      setIsCreating(false);
    } catch (error) {
      console.error('投稿作成エラー:', error);
    }
  };

  const handleUpdatePost = async (
    postId: Id<'posts'>,
    updates: any
  ) => {
    try {
      await updatePost({ postId, ...updates });
    } catch (error) {
      console.error('投稿更新エラー:', error);
    }
  };

  const handleDeletePost = async (postId: Id<'posts'>) => {
    if (confirm('本当に削除しますか?')) {
      try {
        await deletePost({ postId });
        setSelectedPost(null);
      } catch (error) {
        console.error('投稿削除エラー:', error);
      }
    }
  };

  return (
    <div className='blog-manager'>
      <div className='sidebar'>
        <button onClick={() => setIsCreating(true)}>
          新しい投稿
        </button>

        <div className='post-list'>
          {posts?.map((post) => (
            <div
              key={post._id}
              className={`post-item ${
                selectedPost === post._id ? 'active' : ''
              }`}
              onClick={() => setSelectedPost(post._id)}
            >
              <h4>{post.title}</h4>
              <p>{post.excerpt}</p>
              <span
                className={`status ${
                  post.isPublished ? 'published' : 'draft'
                }`}
              >
                {post.isPublished ? '公開' : '下書き'}
              </span>
            </div>
          ))}
        </div>
      </div>

      <div className='main-content'>
        {isCreating && (
          <PostEditor
            onSave={handleCreatePost}
            onCancel={() => setIsCreating(false)}
          />
        )}

        {selectedPostData && (
          <PostEditor
            post={selectedPostData}
            onSave={(updates) =>
              handleUpdatePost(selectedPost!, updates)
            }
            onDelete={() => handleDeletePost(selectedPost!)}
          />
        )}
      </div>
    </div>
  );
}

リアルタイムチャット機能

Convex のリアルタイム機能を活用したチャットシステムを実装します。

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

// メッセージ送信
export const sendMessage = mutation({
  args: {
    roomId: v.string(),
    content: v.string(),
    authorId: v.id('users'),
  },
  handler: async (ctx, args) => {
    const messageId = await ctx.db.insert('messages', {
      roomId: args.roomId,
      content: args.content,
      authorId: args.authorId,
      createdAt: Date.now(),
    });

    return await ctx.db.get(messageId);
  },
});

// メッセージ取得(リアルタイム)
export const getMessages = query({
  args: { roomId: v.string() },
  handler: async (ctx, args) => {
    const messages = await ctx.db
      .query('messages')
      .withIndex('by_room', (q) =>
        q.eq('roomId', args.roomId)
      )
      .order('asc')
      .collect();

    // 著者情報も含める
    return await Promise.all(
      messages.map(async (message) => {
        const author = await ctx.db.get(message.authorId);
        return { ...message, author };
      })
    );
  },
});
typescript// components/ChatRoom.tsx
import { 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 {
  roomId: string;
  currentUserId: Id<'users'>;
}

export function ChatRoom({
  roomId,
  currentUserId,
}: ChatRoomProps) {
  const [message, setMessage] = useState('');
  const messagesEndRef = useRef<HTMLDivElement>(null);

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

  // 新しいメッセージが追加されたら自動スクロール
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({
      behavior: 'smooth',
    });
  }, [messages]);

  const handleSend = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!message.trim()) return;

    try {
      await sendMessage({
        roomId,
        content: message.trim(),
        authorId: currentUserId,
      });
      setMessage('');
    } catch (error) {
      console.error('メッセージ送信エラー:', error);
    }
  };

  return (
    <div className='chat-room'>
      <div className='messages'>
        {messages?.map((msg) => (
          <div
            key={msg._id}
            className={`message ${
              msg.authorId === currentUserId
                ? 'own'
                : 'other'
            }`}
          >
            <div className='message-header'>
              <span className='author'>
                {msg.author?.name}
              </span>
              <span className='timestamp'>
                {new Date(
                  msg.createdAt
                ).toLocaleTimeString()}
              </span>
            </div>
            <div className='message-content'>
              {msg.content}
            </div>
          </div>
        ))}
        <div ref={messagesEndRef} />
      </div>

      <form onSubmit={handleSend} className='message-form'>
        <input
          type='text'
          value={message}
          onChange={(e) => setMessage(e.target.value)}
          placeholder='メッセージを入力...'
          disabled={!messages}
        />
        <button type='submit' disabled={!message.trim()}>
          送信
        </button>
      </form>
    </div>
  );
}
mermaidsequenceDiagram
    participant User1 as ユーザー1
    participant User2 as ユーザー2
    participant Convex as Convex Backend
    participant DB as Database

    User1->>Convex: sendMessage()
    Convex->>DB: メッセージ挿入
    DB-->>Convex: 挿入完了
    Convex-->>User1: 送信完了
    Convex-->>User2: リアルタイム更新
    User2->>User2: 画面自動更新

リアルタイムチャットでは、ユーザーがメッセージを送信すると即座に他のユーザーの画面にも反映されます。

認証連携パターン

Convex と認証プロバイダーを連携させた実装例です。

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

// ユーザー登録・更新
export const createOrUpdateUser = mutation({
  args: {
    email: v.string(),
    name: v.string(),
    avatarUrl: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    // 既存ユーザーを確認
    const existingUser = await ctx.db
      .query('users')
      .withIndex('by_email', (q) =>
        q.eq('email', args.email)
      )
      .first();

    if (existingUser) {
      // 既存ユーザーの情報を更新
      await ctx.db.patch(existingUser._id, {
        name: args.name,
        avatarUrl: args.avatarUrl,
      });
      return existingUser._id;
    } else {
      // 新規ユーザーを作成
      return await ctx.db.insert('users', {
        ...args,
        createdAt: Date.now(),
      });
    }
  },
});

// 現在のユーザー情報取得
export const getCurrentUser = query({
  args: { email: v.string() },
  handler: async (ctx, args) => {
    return await ctx.db
      .query('users')
      .withIndex('by_email', (q) =>
        q.eq('email', args.email)
      )
      .first();
  },
});
typescript// hooks/useAuth.ts
import { useEffect, useState } from 'react';
import { useQuery, useMutation } from 'convex/react';
import { api } from '../convex/_generated/api';

interface User {
  _id: string;
  email: string;
  name: string;
  avatarUrl?: string;
}

export function useAuth() {
  const [authUser, setAuthUser] = useState<any>(null);
  const [loading, setLoading] = useState(true);

  // Convexユーザー情報を取得
  const convexUser = useQuery(
    api.auth.getCurrentUser,
    authUser ? { email: authUser.email } : 'skip'
  );

  const createOrUpdateUser = useMutation(
    api.auth.createOrUpdateUser
  );

  useEffect(() => {
    // 認証プロバイダー(例:Firebase Auth)からユーザー情報を取得
    const unsubscribe = onAuthStateChanged((user) => {
      setAuthUser(user);
      setLoading(false);

      if (user) {
        // Convexにユーザー情報を同期
        createOrUpdateUser({
          email: user.email,
          name: user.displayName || '匿名ユーザー',
          avatarUrl: user.photoURL,
        });
      }
    });

    return () => unsubscribe();
  }, [createOrUpdateUser]);

  return {
    authUser,
    convexUser,
    loading,
    isAuthenticated: !!authUser,
  };
}

これらの実践例により、Convex を使用した本格的なアプリケーション開発の基盤が整います。次のセクションでは、パフォーマンス最適化のテクニックを詳しく解説します。

パフォーマンス最適化

クエリの効率化

Convex アプリケーションのパフォーマンスを最大化するため、効率的なクエリ設計とデータベースインデックスの活用が重要です。

インデックス設計の最適化

typescript// convex/schema.ts - 効率的なインデックス設計
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';

export default defineSchema({
  posts: defineTable({
    title: v.string(),
    content: v.string(),
    authorId: v.id('users'),
    categoryId: v.id('categories'),
    isPublished: v.boolean(),
    publishedAt: v.optional(v.number()),
    createdAt: v.number(),
    viewCount: v.number(),
  })
    // 複合インデックスで効率的な検索を実現
    .index('by_published_date', [
      'isPublished',
      'publishedAt',
    ])
    .index('by_author_published', [
      'authorId',
      'isPublished',
    ])
    .index('by_category_published', [
      'categoryId',
      'isPublished',
    ])
    .index('by_view_count', ['viewCount'])
    .index('by_created_date', ['createdAt']),

  comments: defineTable({
    postId: v.id('posts'),
    authorId: v.id('users'),
    content: v.string(),
    isApproved: v.boolean(),
    createdAt: v.number(),
  })
    .index('by_post_approved', ['postId', 'isApproved'])
    .index('by_author', ['authorId']),
});

効率的なクエリパターン

typescript// convex/posts.ts - 最適化されたクエリ実装
import { query } from './_generated/server';
import { v } from 'convex/values';

// ページネーション対応の効率的なクエリ
export const getPostsPaginated = query({
  args: {
    categoryId: v.optional(v.id('categories')),
    cursor: v.optional(v.string()),
    limit: v.optional(v.number()),
  },
  handler: async (ctx, args) => {
    const limit = Math.min(args.limit || 10, 50); // 最大50件に制限

    let query = ctx.db.query('posts');

    if (args.categoryId) {
      // カテゴリフィルタリング時は専用インデックスを使用
      query = query.withIndex(
        'by_category_published',
        (q) =>
          q
            .eq('categoryId', args.categoryId)
            .eq('isPublished', true)
      );
    } else {
      // 全体検索時は公開日でソート
      query = query.withIndex('by_published_date', (q) =>
        q.eq('isPublished', true)
      );
    }

    return await query.order('desc').paginate({
      numItems: limit,
      cursor: args.cursor,
    });
  },
});

// 関連データの効率的な取得
export const getPostWithRelatedData = query({
  args: { postId: v.id('posts') },
  handler: async (ctx, args) => {
    const post = await ctx.db.get(args.postId);
    if (!post) return null;

    // 並列で関連データを取得
    const [author, category, comments, relatedPosts] =
      await Promise.all([
        ctx.db.get(post.authorId),
        ctx.db.get(post.categoryId),
        // 承認済みコメントのみ取得
        ctx.db
          .query('comments')
          .withIndex('by_post_approved', (q) =>
            q
              .eq('postId', args.postId)
              .eq('isApproved', true)
          )
          .order('asc')
          .take(20)
          .collect(),
        // 同じカテゴリの関連投稿を取得
        ctx.db
          .query('posts')
          .withIndex('by_category_published', (q) =>
            q
              .eq('categoryId', post.categoryId)
              .eq('isPublished', true)
          )
          .filter((q) => q.neq(q.field('_id'), args.postId))
          .order('desc')
          .take(5)
          .collect(),
      ]);

    return {
      post,
      author,
      category,
      comments,
      relatedPosts,
    };
  },
});

クエリの分割とキャッシュ最適化

typescript// components/PostDetail.tsx - 効率的なデータ取得
import { useQuery } from 'convex/react';
import { api } from '../convex/_generated/api';
import { Id } from '../convex/_generated/dataModel';

interface PostDetailProps {
  postId: Id<'posts'>;
}

export function PostDetail({ postId }: PostDetailProps) {
  // メインデータと重要でないデータを分離
  const post = useQuery(api.posts.getPostById, { postId });
  const comments = useQuery(api.comments.getByPost, {
    postId,
  });
  const relatedPosts = useQuery(api.posts.getRelatedPosts, {
    postId,
    limit: 3,
  });

  // ビューカウントは非同期で更新(UX優先)
  const incrementViewCount = useMutation(
    api.posts.incrementViewCount
  );

  useEffect(() => {
    if (post) {
      // ビューカウント更新は非同期実行
      incrementViewCount({ postId }).catch(console.error);
    }
  }, [post, postId, incrementViewCount]);

  if (post === undefined) {
    return <PostDetailSkeleton />;
  }

  return (
    <article className='post-detail'>
      <PostHeader post={post} />
      <PostContent content={post.content} />

      {/* コメント部分は遅延読み込み */}
      <Suspense fallback={<CommentsSkeleton />}>
        <CommentsSection comments={comments} />
      </Suspense>

      {/* 関連投稿も遅延読み込み */}
      <Suspense fallback={<RelatedPostsSkeleton />}>
        <RelatedPosts posts={relatedPosts} />
      </Suspense>
    </article>
  );
}

レンダリング最適化テクニック

React.memo や useMemo を活用して、不要な再レンダリングを防ぎます。

コンポーネントのメモ化

typescript// components/PostCard.tsx - React.memoでパフォーマンス最適化
import React, { memo } from 'react';
import { Id } from '../convex/_generated/dataModel';

interface PostCardProps {
  post: {
    _id: Id<'posts'>;
    title: string;
    excerpt: string;
    authorName: string;
    publishedAt: number;
    viewCount: number;
  };
  onLike?: (postId: Id<'posts'>) => void;
}

export const PostCard = memo(function PostCard({
  post,
  onLike,
}: PostCardProps) {
  // 日付フォーマットをメモ化
  const formattedDate = useMemo(() => {
    return new Date(post.publishedAt).toLocaleDateString(
      'ja-JP'
    );
  }, [post.publishedAt]);

  // いいねハンドラーをメモ化
  const handleLike = useCallback(() => {
    onLike?.(post._id);
  }, [post._id, onLike]);

  return (
    <div className='post-card'>
      <h3>{post.title}</h3>
      <p className='excerpt'>{post.excerpt}</p>
      <div className='post-meta'>
        <span>by {post.authorName}</span>
        <time>{formattedDate}</time>
        <span>{post.viewCount} views</span>
      </div>
      <button onClick={handleLike}>いいね</button>
    </div>
  );
});

// 比較関数でより細かい制御
export const PostCardOptimized = memo(
  PostCard,
  (prevProps, nextProps) => {
    // 必要なプロパティのみ比較
    return (
      prevProps.post._id === nextProps.post._id &&
      prevProps.post.title === nextProps.post.title &&
      prevProps.post.viewCount === nextProps.post.viewCount
    );
  }
);

仮想化を使用した大量データの効率的表示

typescript// components/VirtualizedPostList.tsx - 大量データの効率的表示
import { FixedSizeList as List } from 'react-window';
import { useQuery } from 'convex/react';
import { api } from '../convex/_generated/api';

interface VirtualizedPostListProps {
  categoryId?: string;
}

export function VirtualizedPostList({
  categoryId,
}: VirtualizedPostListProps) {
  const posts = useQuery(api.posts.getAllPosts, {
    categoryId,
    limit: 1000, // 大量データを取得
  });

  const Row = useCallback(
    ({ index, style }: any) => {
      const post = posts?.[index];
      if (!post) return <div style={style}>Loading...</div>;

      return (
        <div style={style}>
          <PostCard post={post} />
        </div>
      );
    },
    [posts]
  );

  if (!posts) return <div>Loading...</div>;

  return (
    <List
      height={600} // 表示エリアの高さ
      itemCount={posts.length}
      itemSize={200} // 各アイテムの高さ
      width='100%'
    >
      {Row}
    </List>
  );
}

効率的な状態管理

typescript// hooks/usePostsOptimized.ts - 効率的な状態管理
import { useQuery, useMutation } from 'convex/react';
import { api } from '../convex/_generated/api';
import { useMemo, useCallback } from 'react';

export function usePostsOptimized(filters: {
  categoryId?: string;
  authorId?: string;
  published?: boolean;
}) {
  // フィルター条件をメモ化
  const queryArgs = useMemo(
    () => ({
      ...filters,
      limit: 20,
    }),
    [
      filters.categoryId,
      filters.authorId,
      filters.published,
    ]
  );

  const posts = useQuery(
    api.posts.getPostsFiltered,
    queryArgs
  );
  const createPost = useMutation(api.posts.createPost);
  const updatePost = useMutation(api.posts.updatePost);
  const deletePost = useMutation(api.posts.deletePost);

  // 楽観的更新を含むアクション
  const optimisticActions = useMemo(
    () => ({
      create: async (data: any) => {
        try {
          return await createPost(data);
        } catch (error) {
          console.error('投稿作成エラー:', error);
          throw error;
        }
      },

      update: async (postId: string, updates: any) => {
        try {
          return await updatePost({ postId, ...updates });
        } catch (error) {
          console.error('投稿更新エラー:', error);
          throw error;
        }
      },

      delete: async (postId: string) => {
        try {
          return await deletePost({ postId });
        } catch (error) {
          console.error('投稿削除エラー:', error);
          throw error;
        }
      },
    }),
    [createPost, updatePost, deletePost]
  );

  return {
    posts,
    actions: optimisticActions,
    loading: posts === undefined,
    error: posts === null,
  };
}
mermaidflowchart TD
    UserInput[ユーザー入力] -->|フィルタリング| QueryMemo[useMemo でクエリ最適化]
    QueryMemo -->|効率的クエリ| ConvexQuery[Convex クエリ実行]
    ConvexQuery -->|データ取得| DataCache[Convex キャッシュ]

    DataCache -->|メモ化| ComponentMemo[React.memo コンポーネント]
    ComponentMemo -->|仮想化| VirtualList[仮想リスト表示]

    VirtualList -->|表示最適化| RenderOptimized[最適化されたレンダリング]

    UserAction[ユーザーアクション] -->|楽観的更新| OptimisticUpdate[楽観的UI更新]
    OptimisticUpdate -->|並行処理| MutationBatch[バッチ処理]

これらの最適化テクニックにより、大規模なデータを扱うアプリケーションでも快適なユーザー体験を提供できます。

まとめ

Convex と React/Next.js の組み合わせは、モダンな Web アプリケーション開発において強力なソリューションを提供します。本記事では、基礎的な環境構築から実践的な活用例、そしてパフォーマンス最適化まで幅広く解説しました。

Convex 導入の主要メリット

#メリット従来手法との比較
1開発速度の向上API 開発からフロントエンド実装まで一貫した開発体験
2リアルタイム機能WebSocket の複雑な実装が不要、自動的にリアルタイム更新
3型安全性TypeScript との完全統合でバグの早期発見
4状態管理の簡素化キャッシュやローディング状態を自動管理
5スケーラビリティインフラ管理不要で自動スケーリング

図で理解できる要点

  • useQuery: データ取得からキャッシュ管理まで自動化
  • useMutation: 楽観的更新とリアルタイム同期を簡単実装
  • パフォーマンス最適化: インデックス設計と React メモ化の組み合わせ

開発における注意点

Convex を最大限活用するためには、以下の点に注意が必要です。

  • 適切なスキーマ設計: インデックスを効果的に配置し、クエリ性能を最適化する
  • バリデーション戦略: クライアントとサーバーの両方でデータ整合性を保証する
  • エラーハンドリング: ネットワークエラーやデータ不整合に対する適切な処理を実装する

今後の学習方向性

Convex をマスターするための次のステップとして、以下の分野を深く学習することをお勧めします。

  1. 高度なクエリ最適化: 複雑な検索条件やソート処理の効率化
  2. 認証・認可システム: セキュアなアプリケーション構築のための実装パターン
  3. テスト戦略: Convex アプリケーションの効果的なテスト手法
  4. デプロイ・運用: 本番環境での監視と最適化手法

Convex と React/Next.js の組み合わせにより、従来では複雑だったリアルタイムアプリケーションの開発が劇的に簡素化されます。本記事で紹介したパターンを参考に、ぜひ皆さんのプロジェクトで Convex を活用してみてください。

効率的で保守性の高いモダンな Web アプリケーション開発への第一歩として、Convex は非常に有力な選択肢となるでしょう。

関連リンク