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 をマスターするための次のステップとして、以下の分野を深く学習することをお勧めします。
- 高度なクエリ最適化: 複雑な検索条件やソート処理の効率化
- 認証・認可システム: セキュアなアプリケーション構築のための実装パターン
- テスト戦略: Convex アプリケーションの効果的なテスト手法
- デプロイ・運用: 本番環境での監視と最適化手法
Convex と React/Next.js の組み合わせにより、従来では複雑だったリアルタイムアプリケーションの開発が劇的に簡素化されます。本記事で紹介したパターンを参考に、ぜひ皆さんのプロジェクトで Convex を活用してみてください。
効率的で保守性の高いモダンな Web アプリケーション開発への第一歩として、Convex は非常に有力な選択肢となるでしょう。
関連リンク
- article
Convex × React/Next.js 最速連携:useQuery/useMutation の実践パターン
- article
Convex の基本アーキテクチャ徹底解説:データベース・関数・リアルタイム更新
- article
Convex 入門:5 分でリアルタイム DB と関数 API を立ち上げる完全ガイド
- article
Convex × React/Next.js 最速連携:useQuery/useMutation の実践パターン
- article
Next.js の Middleware 活用法:リクエスト制御・認証・リダイレクトの実践例
- article
【解決策】Next.js での CORS エラーの原因と対処方法まとめ
- article
Next.js で環境変数を安全に管理するベストプラクティス
- article
shadcn/ui × Next.js:モダンな UI を爆速構築する方法
- article
Next.js の Image コンポーネント徹底攻略:最適化・レスポンシブ・外部 CDN 対応
- article
Devin のタスク実行能力を検証してみた【実践レポート】
- article
shadcn/ui でダッシュボードをデザインするベストプラクティス
- article
Convex × React/Next.js 最速連携:useQuery/useMutation の実践パターン
- article
Remix の Mutation とサーバーアクション徹底活用
- article
【解決策】Codex API で「Rate Limit Exceeded」が出る原因と回避方法
- article
既存 React プロジェクトを Preact に移行する完全ロードマップ
- 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 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来